From 2e32bae6dbd4e224d6c71932648614eedb7cded7 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Sun, 6 Sep 2026 13:52:51 +0530 Subject: [PATCH] Move accounting reads and tools to .NET ODBC mirror --- .../accounting/accounting_mirror_service.py | 24 + .../templates/accounting/tally.html | 15 - app/modules/documents/agent_package.py | 4 +- .../erp_local_agent/__init__.py | 2 +- .../erp_local_agent/accounting_mirror.py | 691 ++++++++---------- .../erp_local_agent/commands.py | 149 +++- .../ERP_Accounting_Mirror_v3_1.tdl | 207 ++++++ .../TallyAccountingMirrorV31.cs | 199 +++++ .../mirror_runtime/build_act_v3_1.py | 612 ++++++++++++++++ .../erp_local_agent/mirror_tally.py | 170 +++++ .../erp_local_agent/tally.py | 28 - 11 files changed, 1633 insertions(+), 468 deletions(-) create mode 100644 app/modules/documents/local_agent_runtime/erp_local_agent/mirror_runtime/ERP_Accounting_Mirror_v3_1.tdl create mode 100644 app/modules/documents/local_agent_runtime/erp_local_agent/mirror_runtime/TallyAccountingMirrorV31.cs create mode 100644 app/modules/documents/local_agent_runtime/erp_local_agent/mirror_runtime/build_act_v3_1.py create mode 100644 app/modules/documents/local_agent_runtime/erp_local_agent/mirror_tally.py diff --git a/app/modules/accounting/accounting_mirror_service.py b/app/modules/accounting/accounting_mirror_service.py index f0ac5f5..e975682 100644 --- a/app/modules/accounting/accounting_mirror_service.py +++ b/app/modules/accounting/accounting_mirror_service.py @@ -207,3 +207,27 @@ def mirror_exceptions(*, node_code: str, accounting_payload: dict[str, Any], lim query="exceptions", filters={"limit": limit}, ) + + +def refresh_masters(*, node_code: str, accounting_payload: dict[str, Any], tally_guid: str, + requested_by_user_id: int | None = None) -> dict[str, Any]: + payload = dict(accounting_payload or {}) + payload["tally_guid"] = str(tally_guid or "").strip() + if requested_by_user_id is not None: + payload["requested_by_user_id"] = int(requested_by_user_id) + return _unwrap(request_agent_command( + node_code, "accounting_sync_masters", payload, timeout_seconds=330 + )) + +def refresh_transactions(*, node_code: str, accounting_payload: dict[str, Any], tally_guid: str, + date_from: str, date_to: str, + requested_by_user_id: int | None = None) -> dict[str, Any]: + payload = dict(accounting_payload or {}) + payload.update({"tally_guid": str(tally_guid or "").strip(), + "date_from": str(date_from or "").strip(), + "date_to": str(date_to or "").strip()}) + if requested_by_user_id is not None: + payload["requested_by_user_id"] = int(requested_by_user_id) + return _unwrap(request_agent_command( + node_code, "accounting_sync_transactions", payload, timeout_seconds=330 + )) diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html index 3785def..fb88351 100644 --- a/app/modules/accounting/templates/accounting/tally.html +++ b/app/modules/accounting/templates/accounting/tally.html @@ -53,21 +53,6 @@ -
-
-
-
TDS & Withholding Tax
-
Independent TDS tools covering expenses, purchases, capital purchases, liability entries and downstream reconciliation.
-
- TDS Rule Master -
- -
-
Sales & Income
diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 68e3f81..abf82c7 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.23.0" +ERP_LOCAL_AGENT_VERSION = "1.24.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 accounting, cash-payment, depreciation, storage, tunnel and update features are preserved. Version 1.23.0 adds the read-only Accounting Mirror analytics runtime.\n" + "Existing master/transaction sync and depreciation are preserved. Phase 5 adds neutral historical-ledger evidence read capability for server-side learning.\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 d51b5a1..0d62497 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.23.0" +__version__ = "1.24.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 index 1c48c06..269152a 100644 --- 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 @@ -2,96 +2,86 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone +import csv +import hashlib import json import os from pathlib import Path +import re import shutil import sqlite3 import subprocess import sys -import tempfile from typing import Any - -MIRROR_SCHEMA_VERSION = "3.0.1" +MIRROR_SCHEMA_VERSION = "3.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 _number(value: Any) -> float: + s = _text(value).replace(",", "") + m = re.search(r"[-+]?\d+(?:\.\d+)?", s) + return float(m.group(0)) if m else 0.0 -def _float(value: Any) -> float: - try: - return float(value or 0) - except Exception: - return 0.0 - +def _safe_key(value: str) -> str: + text = re.sub(r"[^A-Za-z0-9_.-]+", "_", _text(value)).strip("_") + return text[:80] or "company" @dataclass(frozen=True) class MirrorPaths: accounting_db: Path - mirror_db: Path + current_mirror: Path + cache_root: 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. - """ + """Canonical .NET ODBC -> .act mirror used by all accounting reads.""" 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) + self.agent_root = Path(__file__).resolve().parents[1] / "data" / "accounting_mirror" + self.agent_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" + stem = accounting_db.stem + runtime_dir = self.agent_root / "runtime" runtime_dir.mkdir(parents=True, exist_ok=True) + cache_root = accounting_db.parent / ".accounting_mirror" + cache_root.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", + current_mirror=accounting_db.with_name(f"{stem}_mirror.act"), + cache_root=cache_root, + work_root=cache_root / "sync_work", + extractor_exe=runtime_dir / "TallyAccountingMirrorV31.exe", + tdl_file=self.runtime_root / "ERP_Accounting_Mirror_v3_1.tdl", + loader_file=self.runtime_root / "build_act_v3_1.py", ) @staticmethod def _find_csc() -> Path: windir = Path(os.environ.get("WINDIR") or r"C:\Windows") - candidates = [ + for candidate in ( 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." - ) + raise RuntimeError("Microsoft .NET Framework 4.x C# compiler was not found.") 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}") + source = self.runtime_root / "TallyAccountingMirrorV31.cs" rebuild = not paths.extractor_exe.is_file() if not rebuild: try: @@ -101,69 +91,123 @@ class AccountingMirrorEngine: 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) + cp = subprocess.run( + [str(csc), "/nologo", "/target:exe", f"/out:{paths.extractor_exe}", + "/reference:System.Data.dll", str(source)], + 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()) + raise RuntimeError("Accounting Mirror .NET build failed: " + ((cp.stdout or "") + "\n" + (cp.stderr or "")).strip()) @staticmethod - def _database_summary(db_path: Path) -> dict[str, Any]: + def _read_csv(path: Path) -> list[dict[str, str]]: + if not path.is_file() or path.stat().st_size <= 3: + return [] + with path.open("r", encoding="utf-8-sig", newline="") as f: + reader = csv.DictReader(f) + if not reader.fieldnames: + return [] + return [{str(k or "").lstrip("$").strip(): (v or "") for k, v in row.items()} for row in reader] + + @staticmethod + def _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() + company = db.execute("SELECT * FROM company_master ORDER BY synced_at DESC LIMIT 1").fetchone() + sync = db.execute("SELECT * FROM sync_state ORDER BY last_sync_at DESC LIMIT 1").fetchone() + tables = [ + "ledger_master","stock_item_master","stock_item_hsn_detail","stock_item_gst_detail", + "voucher","voucher_ledger_entry","inventory_movement", + "group_master","voucher_type_master","stock_group_master","stock_category_master", + "unit_master","cost_centre_master","cost_category_master", + ] counts = {} - for table in ( - "ledger_master", - "stock_item_master", - "stock_item_hsn_detail", - "stock_item_gst_detail", - "voucher", - "voucher_ledger_entry", - "inventory_movement", - ): + for table in tables: 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] - ) + unbalanced = int(db.execute( + "SELECT COUNT(*) FROM voucher_balance_check WHERE ABS(COALESCE(difference,0))>0.01" + ).fetchone()[0]) except sqlite3.Error: - pass + unbalanced = 0 return { - "ready": True, - "path": str(db_path), - "size_bytes": db_path.stat().st_size, + "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, + "sync": dict(sync) if sync else {}, "counts": counts, "unbalanced_vouchers": unbalanced, } finally: db.close() + @staticmethod + def _add_auxiliary_masters(db_path: Path, output_dir: Path) -> None: + specs = { + "group_master": ("group_master.csv", [ + ("master_guid","TEXT"),("name","TEXT"),("parent","TEXT"),("reserved_name","TEXT"), + ("is_revenue","TEXT"),("is_deemed_positive","TEXT"),("is_active","TEXT") + ]), + "voucher_type_master": ("voucher_type_master.csv", [ + ("master_guid","TEXT"),("name","TEXT"),("parent","TEXT"),("numbering_method","TEXT"),("is_active","TEXT") + ]), + "stock_group_master": ("stock_group_master.csv", [ + ("master_guid","TEXT"),("name","TEXT"),("parent","TEXT"),("base_units","TEXT"), + ("gst_applicable","TEXT"),("gst_type_of_supply","TEXT"),("is_active","TEXT") + ]), + "stock_category_master": ("stock_category_master.csv", [ + ("master_guid","TEXT"),("name","TEXT"),("parent","TEXT"),("is_active","TEXT") + ]), + "unit_master": ("unit_master.csv", [ + ("master_guid","TEXT"),("name","TEXT"),("original_name","TEXT"),("is_simple_unit","TEXT"), + ("base_units","TEXT"),("additional_units","TEXT"),("conversion","REAL") + ]), + "cost_centre_master": ("cost_centre_master.csv", [ + ("master_guid","TEXT"),("name","TEXT"),("parent","TEXT"),("is_active","TEXT") + ]), + "cost_category_master": ("cost_category_master.csv", [ + ("master_guid","TEXT"),("name","TEXT"),("parent","TEXT"),("is_active","TEXT") + ]), + } + db = sqlite3.connect(db_path, timeout=30) + try: + for table, (filename, cols) in specs.items(): + db.execute(f"DROP TABLE IF EXISTS {table}") + db.execute(f"CREATE TABLE {table} ({', '.join(name+' '+typ for name,typ in cols)})") + rows = AccountingMirrorEngine._read_csv(output_dir / filename) + if not rows: + continue + names = [name for name,_ in cols] + placeholders = ",".join("?" for _ in names) + for row in rows: + values = [] + for name in names: + source = { + "master_guid":"GUID","name":"Name","parent":"Parent","reserved_name":"ReservedName", + "is_revenue":"IsRevenue","is_deemed_positive":"IsDeemedPositive","is_active":"IsActive", + "numbering_method":"NumberingMethod","base_units":"BaseUnits","gst_applicable":"GSTApplicable", + "gst_type_of_supply":"GSTTypeofSupply","original_name":"OriginalName", + "is_simple_unit":"IsSimpleUnit","additional_units":"AdditionalUnits","conversion":"Conversion", + }.get(name, name) + value = row.get(source, "") + if name == "conversion": + value = _number(value) + else: + value = _text(value) + values.append(value) + db.execute(f"INSERT INTO {table} ({','.join(names)}) VALUES ({placeholders})", values) + db.commit() + finally: + db.close() + + def _cache_file(self, paths: MirrorPaths, company_name: str, company_guid: str = "") -> Path: + digest = hashlib.sha256((_text(company_guid) + "|" + _text(company_name).casefold()).encode()).hexdigest()[:12] + return paths.cache_root / f"{_safe_key(company_name)}_{digest}.act" + def status(self, client_id: int) -> dict[str, Any]: paths = self.paths(client_id) return { @@ -173,344 +217,219 @@ class AccountingMirrorEngine: "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), + "mirror_db_path": str(paths.current_mirror), + "mirror": self._summary(paths.current_mirror), } - 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]: + 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, + output_dir = paths.work_root / "mirror_output_v3_1" + cp = subprocess.run( + [str(paths.extractor_exe), "--dsn", dsn or DEFAULT_DSN, "--out", str(output_dir)], + capture_output=True, text=True, timeout=max(60, int(timeout_seconds)) ) - 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") + log = ((cp.stdout or "") + "\n" + (cp.stderr or "")).strip() + (paths.work_root / "extractor.log").write_text(log, encoding="utf-8") if cp.returncode != 0: - raise RuntimeError("Accounting Mirror ODBC extraction failed. " + extraction_log[-4000:]) + raise RuntimeError("Accounting Mirror ODBC extraction failed: " + log[-4000:]) - loader_copy = paths.work_root / "build_act_v3_0_1.py" + loader_copy = paths.work_root / "build_act_v3_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)), + lp = 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:]) + llog = ((lp.stdout or "") + "\n" + (lp.stderr or "")).strip() + (paths.work_root / "loader.log").write_text(llog, encoding="utf-8") + if lp.returncode != 0: + raise RuntimeError("Accounting Mirror SQLite build failed: " + llog[-4000:]) + # Loader v3.0.1 writes this name even when invoked as v3.1. candidate = output_dir / "accounting_mirror_v3_0_1.act" if not candidate.is_file(): - raise RuntimeError("Accounting Mirror SQLite database was not produced.") + raise RuntimeError("Accounting Mirror database was not produced.") + self._add_auxiliary_masters(candidate, output_dir) - candidate_summary = self._database_summary(candidate) - company = candidate_summary.get("company") or {} + summary = self._summary(candidate) + company = 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_name and actual_name.casefold() != exp_name.casefold(): + raise RuntimeError(f"Active Tally company mismatch. ERP expected '{exp_name}', ODBC returned '{actual_name}'.") 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() + raise RuntimeError(f"Tally company GUID mismatch. ERP expected {exp_guid}, ODBC returned {actual_guid}.") + paths.current_mirror.parent.mkdir(parents=True, exist_ok=True) + temp = paths.current_mirror.with_suffix(".act.new") + if temp.exists(): + temp.unlink() src = sqlite3.connect(candidate, timeout=60) - dst = sqlite3.connect(temp_target, timeout=60) + dst = sqlite3.connect(temp, timeout=60) try: - src.backup(dst) - dst.commit() + src.backup(dst); dst.commit() finally: - dst.close() - src.close() - temp_target.replace(paths.mirror_db) + dst.close(); src.close() + temp.replace(paths.current_mirror) - 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"), - ) + cache = self._cache_file(paths, actual_name, actual_guid) + shutil.copy2(paths.current_mirror, cache) return { - "ok": True, - "synced_at_utc": _utc_now(), - "extractor_log_tail": extraction_log[-2000:], - "loader_log_tail": loader_log[-2000:], + "ok": True, "synced_at_utc": _utc_now(), "company_name": actual_name, + "company_guid": actual_guid, "company_cache_path": str(cache), + "extractor_log_tail": log[-1500:], "loader_log_tail": llog[-1500:], **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) + def company_db(self, client_id: int, company_name: str, company_guid: str = "") -> Path: + paths = self.paths(client_id) + current = self._summary(paths.current_mirror) + c = current.get("company") or {} + if current.get("ready") and _text(c.get("company_name")).casefold() == _text(company_name).casefold(): + if not company_guid or not _text(c.get("company_guid")) or _text(c.get("company_guid")) == _text(company_guid): + return paths.current_mirror + exact = self._cache_file(paths, company_name, company_guid) + if exact.is_file(): + return exact + # GUID may be omitted by callers. Search only for an exact company-name prefix. + prefix = _safe_key(company_name) + "_" + candidates = sorted(paths.cache_root.glob(prefix + "*.act"), key=lambda p: p.stat().st_mtime, reverse=True) + for candidate in candidates: + s = self._summary(candidate) + c = s.get("company") or {} + if _text(c.get("company_name")).casefold() == _text(company_name).casefold(): + return candidate + raise RuntimeError( + f"No .NET/.act mirror snapshot is cached for Tally company '{company_name}'. " + "Make that company active in TallyPrime and run Master/Transaction Sync once." + ) + + def open_db(self, client_id: int, company_name: str, company_guid: str = "") -> sqlite3.Connection: + db = sqlite3.connect(self.company_db(client_id, company_name, company_guid), 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) + def master_snapshot(self, client_id: int, company_name: str, company_guid: str = "") -> dict[str, list[dict]]: + db = self.open_db(client_id, company_name, company_guid) 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." - ) + def rows(sql, params=()): + return [dict(r) for r in db.execute(sql, params).fetchall()] + ledgers = [] + for r in rows("SELECT * FROM ledger_master ORDER BY ledger_name"): + ledgers.append({ + "guid": r.get("ledger_guid") or "", "name": r.get("ledger_name") or "", + "parent": r.get("parent_group") or "", "opening_balance": float(r.get("opening_balance") or 0), + "closing_balance": float(r.get("closing_balance") or 0), "is_revenue": r.get("is_revenue") or "", + "gst_registration_type": r.get("gst_registration_type") or "", + "gst_applicable": "", "gst_type_of_supply": "", "hsn_code": "", + "is_active": "Yes", + }) + stock_items = [] + for r in rows("SELECT * FROM stock_item_master ORDER BY product_name"): + stock_items.append({ + "guid": r.get("stock_item_guid") or "", "name": r.get("product_name") or "", + "parent": r.get("parent_group") or "", "base_units": r.get("base_uom") or "", + "opening_balance": float(r.get("opening_qty") or 0), "opening_value": float(r.get("opening_value") or 0), + "opening_rate": "", "gst_applicable": r.get("gst_applicable") or "", + "gst_type_of_supply": r.get("gst_type_of_supply") or "", "hsn_code": r.get("current_hsn") or "", + "is_active": "Yes", + }) + def aux(table): + try: + return rows(f"SELECT * FROM {table} ORDER BY name") + except sqlite3.Error: + return [] + groups = [{ + "guid":r.get("master_guid") or "", "name":r.get("name") or "", "parent":r.get("parent") or "", + "reserved_name":r.get("reserved_name") or "", "is_revenue":r.get("is_revenue") or "", + "is_deemed_positive":r.get("is_deemed_positive") or "", "is_active":r.get("is_active") or "", + } for r in aux("group_master")] + voucher_types = [{ + "guid":r.get("master_guid") or "", "name":r.get("name") or "", "parent":r.get("parent") or "", + "numbering_method":r.get("numbering_method") or "", "is_active":r.get("is_active") or "", + } for r in aux("voucher_type_master")] + stock_groups = [{ + "guid":r.get("master_guid") or "", "name":r.get("name") or "", "parent":r.get("parent") or "", + "base_units":r.get("base_units") or "", "gst_applicable":r.get("gst_applicable") or "", + "gst_type_of_supply":r.get("gst_type_of_supply") or "", "is_active":r.get("is_active") or "", + } for r in aux("stock_group_master")] + stock_categories = [{"guid":r.get("master_guid") or "", "name":r.get("name") or "", + "parent":r.get("parent") or "", "is_active":r.get("is_active") or ""} + for r in aux("stock_category_master")] + units = [{"guid":r.get("master_guid") or "", "name":r.get("name") or "", + "original_name":r.get("original_name") or "", "is_simple_unit":r.get("is_simple_unit") or "", + "base_units":r.get("base_units") or "", "additional_units":r.get("additional_units") or "", + "conversion":float(r.get("conversion") or 0)} for r in aux("unit_master")] + cost_centres = [{"guid":r.get("master_guid") or "", "name":r.get("name") or "", + "parent":r.get("parent") or "", "is_active":r.get("is_active") or ""} + for r in aux("cost_centre_master")] + cost_categories = [{"guid":r.get("master_guid") or "", "name":r.get("name") or "", + "parent":r.get("parent") or "", "is_active":r.get("is_active") or ""} + for r in aux("cost_category_master")] + return { + "groups": groups, "ledgers": ledgers, "voucher_types": voucher_types, + "stock_groups": stock_groups, "stock_categories": stock_categories, + "stock_items": stock_items, "units": units, "cost_centres": cost_centres, + "cost_categories": cost_categories, + } + finally: + db.close() + + def transactions(self, client_id: int, company_name: str, date_from: str, date_to: str, + company_guid: str = "") -> dict[str, Any]: + db = self.open_db(client_id, company_name, company_guid) + try: + vouchers = [] + for v in db.execute( + """SELECT * FROM voucher WHERE voucher_date>=? AND voucher_date<=? + ORDER BY voucher_date,voucher_type,voucher_number""", + (date_from, date_to), + ).fetchall(): + guid = v["voucher_guid"] + ledger_entries = [] + for e in db.execute( + "SELECT * FROM voucher_ledger_entry WHERE voucher_guid=? ORDER BY line_no", (guid,) + ).fetchall(): + raw = float(e["raw_amount"] or 0) + ledger_entries.append({ + "ledger_name": e["ledger_name"] or "", + "amount": raw, + "is_deemed_positive": e["raw_is_deemed_positive"] or ("Yes" if raw < 0 else "No"), + "bill_allocations": [], "cost_centre_allocations": [], "bank_allocations": [], + }) + inventory_entries = [] + for e in db.execute( + "SELECT * FROM inventory_movement WHERE voucher_guid=? ORDER BY line_no", (guid,) + ).fetchall(): + normalized = abs(float(e["value"] or 0)) + raw_value = -normalized if str(e["direction"] or "").upper() == "INWARD" else normalized + inventory_entries.append({ + "stock_item_name": e["stock_item_name"] or "", + "is_deemed_positive": e["raw_is_deemed_positive"] or "", + "actual_qty": e["actual_qty_text"] or "", + "billed_qty": e["billed_qty_text"] or "", + "rate": e["rate_text"] or "", + "amount": raw_value, + "godown_name": "", "batch_name": "", "tracking_number": "", "order_number": "", + "hsn_code": e["hsn"] or "", "gst_rate": e["gst_rate"], + }) + vouchers.append({ + "guid": guid, "master_id": str(v["master_id"] or ""), "alter_id": str(v["alter_id"] or ""), + "voucher_key": guid, "date": v["voucher_date"] or "", "effective_date": v["voucher_date"] or "", + "voucher_type_name": v["voucher_type"] or "", "voucher_number": v["voucher_number"] or "", + "reference": v["reference"] or "", "narration": v["narration"] or "", + "party_ledger_name": v["party_ledger"] or "", "is_cancelled": "Yes" if v["is_cancelled"] else "No", + "is_optional": "Yes" if v["is_optional"] else "No", "is_post_dated": "No", + "persisted_view": "", "ledger_entries": ledger_entries, "inventory_entries": inventory_entries, + "raw_xml": "", + }) + return {"company_name": company_name, "date_from": date_from, "date_to": date_to, "vouchers": vouchers} 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 a1cde59..137a78a 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 @@ -11,9 +11,8 @@ from typing import Any from . import __version__ from .accounting_store import LocalAccountingStore -from .tally import TallyLiveConnector +from .mirror_tally import MirrorFirstTallyConnector from .native_voucher_engine import NativeVoucherEngine -from .accounting_mirror import AccountingMirrorEngine _CASH_TALLY_EXTRACTION_LOCK = threading.Lock() @@ -66,8 +65,7 @@ class AgentCommandProcessor: self.config = config self.logger = logger self.store = LocalAccountingStore(config.storage_root) - self.tally = TallyLiveConnector() - self.mirror = AccountingMirrorEngine(self.store, logger) + self.tally = MirrorFirstTallyConnector(self.store, logger) def process(self, command: dict[str, Any]) -> dict[str, Any]: command_id = str(command.get("command_id") or "").strip() @@ -164,8 +162,10 @@ class AgentCommandProcessor: client_id = payload.get("client_id") relative_dir = str(payload.get("accounting_relative_dir") or "").strip() if client_id in (None, "") or not relative_dir: + self.tally.bind_context(None, payload) return self.store.bind_client_path(int(client_id), relative_dir) + self.tally.bind_context(int(client_id), payload) def _agent_info(self) -> dict[str, Any]: return { @@ -173,6 +173,8 @@ class AgentCommandProcessor: "tally_capability": True, "accounting_act_capability": True, "tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "native_voucher_engine_capability": True, "stock_item_native_voucher_capability": True, "stock_master_intelligence_capability": True, "opening_balance_correction_capability": True, "bank_reconciliation_read_capability": True, "tally_transaction_sync_capability": True, + "dotnet_odbc_mirror_capability": True, + "mirror_first_accounting_reads": True, "historical_learning_read_capability": True, "purchase_posting_preflight_capability": True, "purchase_voucher_write_capability": True, @@ -277,6 +279,7 @@ class AgentCommandProcessor: if not company: raise ValueError("The selected mapped Tally company is not currently open in TallyPrime. Open it in Tally and refresh the ERP page.") company_name = str(company.get("name") or mapping.get("company_name") or "").strip() + self.tally.refresh_mirror(company_name, requested_guid) masters = self.tally.fetch_accounting_masters(company_name) self.store.record_tally_status(client_id, tally_status) sync = self.store.replace_master_snapshot(client_id, mapping={**mapping, "company_name": company_name}, masters=masters, requested_by_user_id=int(requested_by_user_id) if requested_by_user_id not in (None, "") else None) @@ -313,6 +316,7 @@ class AgentCommandProcessor: if not company: raise ValueError("The selected mapped Tally company is not currently open in TallyPrime. Open it in Tally and refresh the ERP page.") company_name = str(company.get("name") or mapping.get("company_name") or "").strip() + self.tally.refresh_mirror(company_name, requested_guid) transactions = self.tally.fetch_accounting_transactions(company_name, date_from, date_to) self.store.record_tally_status(client_id, tally_status) sync = self.store.replace_transaction_snapshot( @@ -388,7 +392,9 @@ class AgentCommandProcessor: or "" ).strip() - # Read-only live refresh. No voucher is created or altered. + # Refresh the canonical .NET ODBC mirror once, then aggregate entirely + # from local SQLite. No XML voucher export is used for historical reads. + self.tally.refresh_mirror(company_name, requested_guid) transactions = self.tally.fetch_accounting_transactions( company_name, date_from, @@ -505,6 +511,7 @@ class AgentCommandProcessor: self.tally.alter_stock_item_opening(current.name,stock_item_name=str(req.get("name") or "").strip(),opening_qty=float(req.get("target_opening_qty") or 0),unit=str(req.get("unit") or "").strip(),opening_value=float(req.get("target_opening_value") or 0),opening_rate=float(req.get("target_opening_rate") or 0)) sr.append({"item_id":req.get("item_id"),"verified":None,"target_opening_qty":float(req.get("target_opening_qty") or 0),"target_opening_value":float(req.get("target_opening_value") or 0),"message":"Altered; verification pending."}) except Exception as exc: sr.append({"item_id":req.get("item_id"),"verified":False,"message":str(exc)}) + self.tally.refresh_mirror(current.name, str(current.guid or "")) verify=self.tally.fetch_accounting_masters(current.name) vl={str(r.get("name") or "").strip().casefold():r for r in verify.get("ledgers") or []}; vs={str(r.get("name") or "").strip().casefold():r for r in verify.get("stock_items") or []} lem={int(r.get("item_id") or 0):r for r in le}; sem={int(r.get("item_id") or 0):r for r in se} @@ -2679,6 +2686,7 @@ class AgentCommandProcessor: lines=list(payload.get("lines") or []), items=list(payload.get("items") or []), ) + self.tally.refresh_mirror(company_name, str(company.get("guid") or "")) safe_result = { key: value for key, value in result.items() @@ -2710,10 +2718,20 @@ class AgentCommandProcessor: fy_start = str(payload.get("fy_start") or "").strip() fy_end = str(payload.get("fy_end") or "").strip() if not tally_guid: raise ValueError("Select a mapped Tally company for depreciation.") + sync_payload = dict(payload) + sync_payload["date_from"] = fy_start + sync_payload["date_to"] = fy_end + self._sync_masters(sync_payload) + self._sync_transactions(sync_payload) return {"preview": self.store.depreciation_preview(client_id, tally_guid=tally_guid, fy_start=fy_start, fy_end=fy_end), "agent": self._agent_info()} def _calculate_it_depreciation(self, payload: dict[str, Any]) -> dict[str, Any]: client_id = int(payload.get("client_id")) + sync_payload = dict(payload) + sync_payload["date_from"] = str(payload.get("fy_start") or "").strip() + sync_payload["date_to"] = str(payload.get("fy_end") or "").strip() + self._sync_masters(sync_payload) + self._sync_transactions(sync_payload) result = self.store.calculate_it_depreciation( client_id, tally_guid=str(payload.get("tally_guid") or "").strip(), @@ -2805,45 +2823,104 @@ class AgentCommandProcessor: 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) + return self.tally.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), - ) + company_name = str(payload.get("company_name") or payload.get("expected_company_name") or "").strip() + company_guid = str(payload.get("company_guid") or payload.get("tally_guid") or "").strip() + self.tally.bind_context(client_id, payload) + return self.tally.refresh_mirror(company_name, company_guid, 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.") + self.tally.bind_context(client_id, payload) + query = str(payload.get("query") or payload.get("query_name") or "").strip().lower() 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) + company_name = str(payload.get("company_name") or filters.get("company_name") or "").strip() + tally_guid = str(payload.get("tally_guid") or filters.get("tally_guid") or "").strip() + if not company_name: + summary = self.tally.mirror.status(client_id).get("mirror") or {} + company_name = str((summary.get("company") or {}).get("company_name") or "").strip() + if query == "summary": + return self.tally.mirror.status(client_id) + db = self.tally.mirror.open_db(client_id, company_name, tally_guid) + db.row_factory = __import__("sqlite3").Row + try: + limit = max(1, min(5000, int(filters.get("limit") or 500))) + from_date = str(filters.get("from_date") or "").strip() + to_date = str(filters.get("to_date") or "").strip() + if query == "daybook": + clauses=["1=1"]; params=[] + if from_date: clauses.append("date>=?"); params.append(from_date) + if to_date: clauses.append("date<=?"); params.append(to_date) + rows=[dict(r) for r in db.execute( + "SELECT * FROM daybook_flat WHERE "+" AND ".join(clauses)+" ORDER BY date LIMIT ?", + (*params,limit)).fetchall()] + return {"rows":rows,"count":len(rows)} + if query == "trial_balance": + rows=[dict(r) for r in db.execute( + """SELECT lm.ledger_name,lm.parent_group,lm.opening_balance,lm.closing_balance, + ROUND(COALESCE(SUM(CASE WHEN vle.dr_cr='DR' THEN vle.amount ELSE 0 END),0),2) debit, + ROUND(COALESCE(SUM(CASE WHEN vle.dr_cr='CR' THEN vle.amount ELSE 0 END),0),2) credit + 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,lm.closing_balance + ORDER BY lm.parent_group,lm.ledger_name LIMIT ?""",(limit,)).fetchall()] + return {"rows":rows,"count":len(rows)} + if query == "stock_items": + rows=[dict(r) for r in db.execute("SELECT * FROM stock_item_master ORDER BY product_name LIMIT ?",(limit,)).fetchall()] + return {"rows":rows,"count":len(rows)} + if query == "inventory": + clauses=["1=1"]; params=[] + item=str(filters.get("stock_item_name") or "").strip() + if item: clauses.append("stock_item_name=?"); params.append(item) + if from_date: clauses.append("voucher_date>=?"); params.append(from_date) + if to_date: clauses.append("voucher_date<=?"); params.append(to_date) + rows=[dict(r) for r in db.execute( + "SELECT * FROM inventory_movement WHERE "+" AND ".join(clauses)+" ORDER BY voucher_date LIMIT ?", + (*params,limit)).fetchall()] + return {"rows":rows,"count":len(rows)} + if query == "ledger_transactions": + ledger=str(filters.get("ledger_name") or "").strip() + if not ledger: raise ValueError("ledger_name is required.") + clauses=["ledger_name=?"]; params=[ledger] + if from_date: clauses.append("voucher_date>=?"); params.append(from_date) + if to_date: clauses.append("voucher_date<=?"); params.append(to_date) + rows=[dict(r) for r in db.execute( + "SELECT * FROM voucher_ledger_entry WHERE "+" AND ".join(clauses)+" ORDER BY voucher_date,line_no LIMIT ?", + (*params,limit)).fetchall()] + return {"rows":rows,"count":len(rows)} + if query in {"hsn_history","gst_history"}: + item=str(filters.get("stock_item_name") or "").strip() + if not item: raise ValueError("stock_item_name is required.") + table="stock_item_hsn_detail" if query=="hsn_history" else "stock_item_gst_detail" + rows=[dict(r) for r in db.execute( + f"SELECT * FROM {table} WHERE stock_item_name=? COLLATE NOCASE ORDER BY applicable_from,history_position", + (item,)).fetchall()] + return {"rows":rows,"count":len(rows)} + if query == "voucher": + guid=str(filters.get("voucher_guid") or "").strip() + v=db.execute("SELECT * FROM voucher WHERE voucher_guid=?",(guid,)).fetchone() + return {"voucher":dict(v) if v else None, + "ledger_entries":[dict(r) for r in db.execute("SELECT * FROM voucher_ledger_entry WHERE voucher_guid=? ORDER BY line_no",(guid,)).fetchall()], + "inventory_entries":[dict(r) for r in db.execute("SELECT * FROM inventory_movement WHERE voucher_guid=? ORDER BY line_no",(guid,)).fetchall()]} + if query == "exceptions": + rows=[dict(r) for r in db.execute( + "SELECT * FROM voucher_balance_check WHERE ABS(COALESCE(difference,0))>0.01 LIMIT ?",(limit,)).fetchall()] + return {"unbalanced_vouchers":rows,"count":len(rows)} + if query == "cash_transactions": + ledger=str(filters.get("ledger_name") or "Cash").strip() + clauses=["ledger_name=?"]; params=[ledger] + if from_date: clauses.append("voucher_date>=?"); params.append(from_date) + if to_date: clauses.append("voucher_date<=?"); params.append(to_date) + rows=[dict(r) for r in db.execute( + "SELECT * FROM voucher_ledger_entry WHERE "+" AND ".join(clauses)+" ORDER BY voucher_date LIMIT ?", + (*params,limit)).fetchall()] + return {"rows":rows,"count":len(rows)} + raise ValueError("Unsupported mirror query.") + finally: + db.close() diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/mirror_runtime/ERP_Accounting_Mirror_v3_1.tdl b/app/modules/documents/local_agent_runtime/erp_local_agent/mirror_runtime/ERP_Accounting_Mirror_v3_1.tdl new file mode 100644 index 0000000..16838b7 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/mirror_runtime/ERP_Accounting_Mirror_v3_1.tdl @@ -0,0 +1,207 @@ +;; ============================================================ +;; 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 +;; ============================================================ + +;; ================================================================ +;; ACCOUNTING MIRROR v3.1 - ADDITIONAL MASTER COLLECTIONS +;; These are flat read-only ODBC master tables used to replace the +;; former XML master synchronization path. +;; ================================================================ + +[Collection : ERPAMGroupMaster] + Type : Group + Fetch : Name, GUID, Parent, ReservedName, IsRevenue, IsDeemedPositive, IsActive + Is ODBC Table : Yes + +[Collection : ERPAMVoucherTypeMaster] + Type : VoucherType + Fetch : Name, GUID, Parent, NumberingMethod, IsActive + Is ODBC Table : Yes + +[Collection : ERPAMStockGroupMaster] + Type : StockGroup + Fetch : Name, GUID, Parent, BaseUnits, GSTApplicable, GSTTypeofSupply, IsActive + Is ODBC Table : Yes + +[Collection : ERPAMStockCategoryMaster] + Type : StockCategory + Fetch : Name, GUID, Parent, IsActive + Is ODBC Table : Yes + +[Collection : ERPAMUnitMaster] + Type : Unit + Fetch : Name, GUID, OriginalName, IsSimpleUnit, BaseUnits, AdditionalUnits, Conversion + Is ODBC Table : Yes + +[Collection : ERPAMCostCentreMaster] + Type : CostCentre + Fetch : Name, GUID, Parent, IsActive + Is ODBC Table : Yes + +[Collection : ERPAMCostCategoryMaster] + Type : CostCategory + Fetch : Name, GUID, Parent, IsActive + Is ODBC Table : Yes + diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/mirror_runtime/TallyAccountingMirrorV31.cs b/app/modules/documents/local_agent_runtime/erp_local_agent/mirror_runtime/TallyAccountingMirrorV31.cs new file mode 100644 index 0000000..74c188e --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/mirror_runtime/TallyAccountingMirrorV31.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using System.Data.Odbc; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; + +class TallyAccountingMirrorV31 +{ + 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() diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/mirror_tally.py b/app/modules/documents/local_agent_runtime/erp_local_agent/mirror_tally.py new file mode 100644 index 0000000..5af8448 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/mirror_tally.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import html +import re +from typing import Any + +from .accounting_mirror import AccountingMirrorEngine, DEFAULT_DSN +from .tally import TallyLiveConnector + +class MirrorFirstTallyConnector: + """Compatibility facade. + + All accounting/master/voucher/stock analytical READS are served from the + .NET ODBC Accounting Mirror (.act). The legacy HTTP/XML connector remains + only for company discovery/status and controlled Tally writes. + """ + + def __init__(self, store, logger): + self.store = store + self.logger = logger + self.live = TallyLiveConnector() + self.mirror = AccountingMirrorEngine(store, logger) + self.client_id: int | None = None + self.context: dict[str, Any] = {} + + def bind_context(self, client_id: int | None, payload: dict[str, Any] | None = None): + self.client_id = int(client_id) if client_id not in (None, "") else None + self.context = dict(payload or {}) + + def _client(self) -> int: + if not self.client_id: + raise ValueError("Client accounting context is required for .NET/.act Tally reads.") + return self.client_id + + # Company discovery/status remains the narrow bootstrap channel. + def status(self): + return self.live.status() + + def get_loaded_companies(self): + return self.live.get_loaded_companies() + + def refresh_mirror(self, company_name: str = "", company_guid: str = "", timeout_seconds: int = 300): + client_id = self._client() + expected_name = str(company_name or self.context.get("company_name") or "").strip() + expected_guid = str(company_guid or self.context.get("tally_guid") or "").strip() + return self.mirror.sync( + client_id=client_id, + expected_company_name=expected_name, + expected_company_guid=expected_guid, + dsn=str(self.context.get("dsn") or DEFAULT_DSN), + timeout_seconds=int(timeout_seconds or 300), + ) + + def fetch_accounting_masters(self, company_name: str): + return self.mirror.master_snapshot( + self._client(), company_name, str(self.context.get("tally_guid") or "") + ) + + def fetch_accounting_transactions(self, company_name: str, date_from: str, date_to: str): + return self.mirror.transactions( + self._client(), company_name, date_from, date_to, str(self.context.get("tally_guid") or "") + ) + + def export_master_collection(self, company_name: str, master_key: str): + key = str(master_key or "").strip().lower().replace(" ", "_") + aliases = { + "group":"groups","groups":"groups","ledger":"ledgers","ledgers":"ledgers", + "voucher_type":"voucher_types","voucher_types":"voucher_types", + "stock_group":"stock_groups","stock_groups":"stock_groups", + "stock_category":"stock_categories","stock_categories":"stock_categories", + "stock_item":"stock_items","stock_items":"stock_items", + "unit":"units","units":"units","cost_centre":"cost_centres","cost_centres":"cost_centres", + "cost_center":"cost_centres","cost_centers":"cost_centres", + "cost_category":"cost_categories","cost_categories":"cost_categories", + } + return self.fetch_accounting_masters(company_name).get(aliases.get(key, key), []) + + def export_vouchers(self, company_name: str, date_from: str, date_to: str): + return self.fetch_accounting_transactions(company_name, date_from, date_to).get("vouchers") or [] + + def purchase_posting_ledgers(self, company_name: str) -> list[dict]: + return list(self.fetch_accounting_masters(company_name).get("ledgers") or []) + + @staticmethod + def _ledger_exists(rows: list[dict], name: str) -> bool: + key = str(name or "").strip().casefold() + return bool(key and any(str(r.get("name") or "").strip().casefold() == key for r in rows)) + + @staticmethod + def _tax_ledger_candidates(rows: list[dict], tax: str) -> list[dict]: + target = str(tax or "").strip().upper() + pats = {"IGST":("IGST","INTEGRATED"),"CGST":("CGST","CENTRAL"), + "SGST":("SGST","STATE GST","STATE TAX","UTGST","UT GST"),"CESS":("CESS",)}.get(target,(target,)) + result=[] + for row in rows: + text=" ".join([str(row.get("name") or ""),str(row.get("parent") or ""),str(row.get("tax_type") or "")]).upper() + if any(p in text for p in pats): + result.append(row) + return sorted(result,key=lambda r:str(r.get("name") or "").casefold())[:20] + + def find_purchase_duplicates(self, company_name: str, *, invoice_date: str, reference: str, + party_ledger_names: list[str] | None = None, invoice_value: float = 0): + target_ref=re.sub(r"[^A-Z0-9]","",str(reference or "").upper()) + parties={str(x or "").strip().casefold() for x in (party_ledger_names or []) if str(x or "").strip()} + expected=round(abs(float(invoice_value or 0)),2) + result=[] + for voucher in self.export_vouchers(company_name, invoice_date, invoice_date): + if "PURCHASE" not in str(voucher.get("voucher_type_name") or "").upper(): + continue + ref=re.sub(r"[^A-Z0-9]","",str(voucher.get("reference") or "").upper()) + party=str(voucher.get("party_ledger_name") or "").strip() + if target_ref and ref != target_ref: continue + if parties and party.casefold() not in parties: continue + party_amount=max([abs(float(x.get("amount") or 0)) for x in voucher.get("ledger_entries") or [] + if str(x.get("ledger_name") or "").strip().casefold()==party.casefold()] or [0]) + if expected and party_amount and abs(party_amount-expected)>max(2.0,expected*0.005): continue + result.append({"guid":voucher.get("guid") or "","voucher_number":voucher.get("voucher_number") or "", + "reference":voucher.get("reference") or "","date":voucher.get("date") or "", + "party_ledger_name":party,"party_amount":party_amount}) + return result[:20] + + def purchase_posting_preflight(self, company_name: str, *, supplier_gstin: str, supplier_name: str, + purchase_ledger_name: str, invoice_number: str, invoice_date: str, + invoice_value: float): + rows=self.purchase_posting_ledgers(company_name) + gstin=re.sub(r"[^A-Z0-9]","",str(supplier_gstin or "").upper())[:15] + supplier_key=re.sub(r"[^A-Z0-9]","",str(supplier_name or "").upper()) + gstin_matches=[r for r in rows if gstin and re.sub(r"[^A-Z0-9]","",str(r.get("gstin") or "").upper())[:15]==gstin] + name_matches=[r for r in rows if supplier_key and re.sub(r"[^A-Z0-9]","",str(r.get("name") or "").upper())==supplier_key] + party=gstin_matches or name_matches + if not party and supplier_key: + party=[r for r in rows if supplier_key in re.sub(r"[^A-Z0-9]","",str(r.get("name") or "").upper())][:20] + return { + "party_candidates":party[:20], + "purchase_ledger_verified":self._ledger_exists(rows,purchase_ledger_name), + "igst_candidates":self._tax_ledger_candidates(rows,"IGST"), + "cgst_candidates":self._tax_ledger_candidates(rows,"CGST"), + "sgst_candidates":self._tax_ledger_candidates(rows,"SGST"), + "cess_candidates":self._tax_ledger_candidates(rows,"CESS"), + "duplicate_candidates":self.find_purchase_duplicates( + company_name,invoice_date=invoice_date,reference=invoice_number, + party_ledger_names=[str(r.get("name") or "") for r in party],invoice_value=invoice_value), + "ledger_count":len(rows), + } + + def bank_posting_ledgers(self, company_name: str): + rows=self.purchase_posting_ledgers(company_name) + bank=[r for r in rows if "BANK" in str(r.get("parent") or "").upper()] + return {"bank_ledgers":bank,"all_ledgers":rows} + + def find_bank_voucher_duplicates(self, company_name: str, *, voucher_date: str, voucher_type: str, + reference: str, amount: float): + target_ref=re.sub(r"[^A-Z0-9]","",str(reference or "").upper()) + expected=round(abs(float(amount or 0)),2) + result=[] + for v in self.export_vouchers(company_name,voucher_date,voucher_date): + if str(v.get("voucher_type_name") or "").strip().casefold()!=str(voucher_type or "").strip().casefold(): + continue + ref=re.sub(r"[^A-Z0-9]","",str(v.get("reference") or "").upper()) + if target_ref and ref and ref!=target_ref: continue + vals=[abs(float(x.get("amount") or 0)) for x in v.get("ledger_entries") or []] + if expected and vals and all(abs(x-expected)>max(1.0,expected*0.002) for x in vals): continue + result.append({"guid":v.get("guid") or "","voucher_number":v.get("voucher_number") or "", + "reference":v.get("reference") or "","date":v.get("date") or ""}) + return result[:20] + + # Controlled writes are deliberately delegated to the existing Tally writer. + # Analytical reads and verification are mirror-based. + def __getattr__(self, name: str): + return getattr(self.live, name) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py b/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py index 9167cc9..7a3e437 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py @@ -327,34 +327,6 @@ class TallyLiveConnector: raw = self._post_xml(xml) return self._parse_master_rows(raw, spec["tag"]) - def cash_in_hand_ledgers(self, company_name: str) -> list[dict]: - """Return only ledgers belonging to Tally's Cash-in-Hand group. - - This intentionally avoids enumerating the complete ledger master. Tally's - CHILDOF + BELONGSTO collection semantics include ledgers held directly under - Cash-in-Hand as well as ledgers under descendant groups. - """ - company = str(company_name or "").strip() - if not company: - raise ValueError("Tally company name is required for Cash-in-Hand ledger discovery.") - spec = self.MASTER_SPECS["ledgers"] - xml = f""" -
1ExportCollectionARRRCashInHandLedgers
- - {self._static_variables(company)} - - - Ledger - Cash-in-Hand - Yes - {spec['fetch']} - - - -
""" - raw = self._post_xml(xml) - return self._parse_master_rows(raw, spec["tag"]) - def fetch_accounting_masters(self, company_name: str) -> dict[str, list[dict]]: result: dict[str, list[dict]] = {} for key in self.MASTER_SPECS: