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: