Move accounting reads and tools to .NET ODBC mirror

This commit is contained in:
A R R R Associates
2026-09-06 13:52:51 +05:30
parent 7ae8a40b49
commit 2e32bae6db
11 changed files with 1633 additions and 468 deletions
@@ -207,3 +207,27 @@ def mirror_exceptions(*, node_code: str, accounting_payload: dict[str, Any], lim
query="exceptions", query="exceptions",
filters={"limit": limit}, 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
))
@@ -53,21 +53,6 @@
</div> </div>
</div> </div>
<div class="rounded-2xl border border-violet-200 bg-violet-50/40 p-4">
<div class="mb-3 flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-xs font-semibold uppercase tracking-[0.14em] text-violet-700">TDS &amp; Withholding Tax</div>
<div class="text-xs text-slate-500">Independent TDS tools covering expenses, purchases, capital purchases, liability entries and downstream reconciliation.</div>
</div>
<a href="/tools/accounting/tds/rules" class="rounded-xl border border-violet-300 bg-white px-3 py-2 text-sm font-semibold text-violet-800">TDS Rule Master</a>
</div>
<div class="flex flex-wrap gap-2">
<a href="/tools/accounting/tds/compliance{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-violet-300 bg-white px-3 py-2 text-sm font-semibold text-violet-800">TDS Compliance Review</a>
<a href="/tools/accounting/tds/liability{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-violet-300 bg-white px-3 py-2 text-sm font-semibold text-violet-800">TDS Entry &amp; Liability</a>
<a href="/tools/accounting/tds/reconciliation{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-violet-300 bg-white px-3 py-2 text-sm font-semibold text-violet-800">TDS Reconciliation</a>
</div>
</div>
<div class="rounded-2xl border border-emerald-200 bg-emerald-50/40 p-4"> <div class="rounded-2xl border border-emerald-200 bg-emerald-50/40 p-4">
<div class="mb-3"> <div class="mb-3">
<div class="text-xs font-semibold uppercase tracking-[0.14em] text-emerald-700">Sales & Income</div> <div class="text-xs font-semibold uppercase tracking-[0.14em] text-emerald-700">Sales & Income</div>
+2 -2
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path from pathlib import Path
import zipfile import zipfile
ERP_LOCAL_AGENT_VERSION = "1.23.0" ERP_LOCAL_AGENT_VERSION = "1.24.0"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent" ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) _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 = ( text = (
f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n" f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n"
"Existing storage, WebSocket tunnel, dashboard, Tally mapping and client .act functionality are preserved.\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" "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")) _write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", text.encode("utf-8"))
@@ -1,2 +1,2 @@
__version__ = "1.23.0" __version__ = "1.24.0"
AGENT_NAME = "ERP Local Agent" AGENT_NAME = "ERP Local Agent"
@@ -2,96 +2,86 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
import csv
import hashlib
import json import json
import os import os
from pathlib import Path from pathlib import Path
import re
import shutil import shutil
import sqlite3 import sqlite3
import subprocess import subprocess
import sys import sys
import tempfile
from typing import Any from typing import Any
MIRROR_SCHEMA_VERSION = "3.1"
MIRROR_SCHEMA_VERSION = "3.0.1"
DEFAULT_DSN = "TallyODBC64_9000" DEFAULT_DSN = "TallyODBC64_9000"
def _utc_now() -> str: def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()
def _text(value: Any) -> str: def _text(value: Any) -> str:
return str(value or "").replace("\x04", "").strip() 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: def _safe_key(value: str) -> str:
try: text = re.sub(r"[^A-Za-z0-9_.-]+", "_", _text(value)).strip("_")
return float(value or 0) return text[:80] or "company"
except Exception:
return 0.0
@dataclass(frozen=True) @dataclass(frozen=True)
class MirrorPaths: class MirrorPaths:
accounting_db: Path accounting_db: Path
mirror_db: Path current_mirror: Path
cache_root: Path
work_root: Path work_root: Path
extractor_exe: Path extractor_exe: Path
tdl_file: Path tdl_file: Path
loader_file: Path loader_file: Path
class AccountingMirrorEngine: class AccountingMirrorEngine:
"""Read-only Tally -> local SQLite accounting mirror. """Canonical .NET ODBC -> .act mirror used by all accounting reads."""
The existing client .act database is deliberately left untouched.
The mirror lives beside it as client_XXXXXXXX_mirror.act so existing
accounting, cash-payment, depreciation, storage and review features
continue to use their current database and business logic unchanged.
"""
def __init__(self, store, logger): def __init__(self, store, logger):
self.store = store self.store = store
self.logger = logger self.logger = logger
self.runtime_root = Path(__file__).resolve().parent / "mirror_runtime" self.runtime_root = Path(__file__).resolve().parent / "mirror_runtime"
self.agent_data_root = Path(__file__).resolve().parents[1] / "data" / "accounting_mirror" self.agent_root = Path(__file__).resolve().parents[1] / "data" / "accounting_mirror"
self.agent_data_root.mkdir(parents=True, exist_ok=True) self.agent_root.mkdir(parents=True, exist_ok=True)
def paths(self, client_id: int) -> MirrorPaths: def paths(self, client_id: int) -> MirrorPaths:
accounting_db = self.store.db_path(int(client_id)) accounting_db = self.store.db_path(int(client_id))
key = accounting_db.stem stem = accounting_db.stem
mirror_db = accounting_db.with_name(f"{key}_mirror.act") runtime_dir = self.agent_root / "runtime"
runtime_dir = self.agent_data_root / "runtime"
runtime_dir.mkdir(parents=True, exist_ok=True) 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( return MirrorPaths(
accounting_db=accounting_db, accounting_db=accounting_db,
mirror_db=mirror_db, current_mirror=accounting_db.with_name(f"{stem}_mirror.act"),
work_root=accounting_db.parent / ".mirror_sync", cache_root=cache_root,
extractor_exe=runtime_dir / "TallyAccountingMirrorV301.exe", work_root=cache_root / "sync_work",
tdl_file=self.runtime_root / "ERP_Accounting_Mirror_v3_0_1.tdl", extractor_exe=runtime_dir / "TallyAccountingMirrorV31.exe",
loader_file=self.runtime_root / "build_act_v3_0_1.py", tdl_file=self.runtime_root / "ERP_Accounting_Mirror_v3_1.tdl",
loader_file=self.runtime_root / "build_act_v3_1.py",
) )
@staticmethod @staticmethod
def _find_csc() -> Path: def _find_csc() -> Path:
windir = Path(os.environ.get("WINDIR") or r"C:\Windows") 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/Framework64/v4.0.30319/csc.exe",
windir / "Microsoft.NET/Framework/v4.0.30319/csc.exe", windir / "Microsoft.NET/Framework/v4.0.30319/csc.exe",
] ):
for candidate in candidates:
if candidate.is_file(): if candidate.is_file():
return candidate return candidate
raise RuntimeError( raise RuntimeError("Microsoft .NET Framework 4.x C# compiler was not found.")
"Microsoft .NET Framework C# compiler (csc.exe) was not found. "
"Windows .NET Framework 4.x is required for the Tally ODBC mirror reader."
)
def _ensure_extractor(self, paths: MirrorPaths) -> None: def _ensure_extractor(self, paths: MirrorPaths) -> None:
source = self.runtime_root / "TallyAccountingMirrorV301.cs" source = self.runtime_root / "TallyAccountingMirrorV31.cs"
if not source.is_file():
raise RuntimeError(f"Accounting Mirror C# source is missing: {source}")
rebuild = not paths.extractor_exe.is_file() rebuild = not paths.extractor_exe.is_file()
if not rebuild: if not rebuild:
try: try:
@@ -101,69 +91,123 @@ class AccountingMirrorEngine:
if not rebuild: if not rebuild:
return return
csc = self._find_csc() csc = self._find_csc()
paths.extractor_exe.parent.mkdir(parents=True, exist_ok=True) cp = subprocess.run(
cmd = [ [str(csc), "/nologo", "/target:exe", f"/out:{paths.extractor_exe}",
str(csc), "/reference:System.Data.dll", str(source)],
"/nologo", capture_output=True, text=True, timeout=120,
"/target:exe", )
f"/out:{paths.extractor_exe}",
"/reference:System.Data.dll",
str(source),
]
cp = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if cp.returncode != 0 or not paths.extractor_exe.is_file(): if cp.returncode != 0 or not paths.extractor_exe.is_file():
detail = (cp.stdout or "") + "\n" + (cp.stderr or "") raise RuntimeError("Accounting Mirror .NET build failed: " + ((cp.stdout or "") + "\n" + (cp.stderr or "")).strip())
raise RuntimeError("Accounting Mirror extractor build failed: " + detail.strip())
@staticmethod @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(): if not db_path.is_file():
return {"ready": False} return {"ready": False}
db = sqlite3.connect(db_path, timeout=30) db = sqlite3.connect(db_path, timeout=30)
db.row_factory = sqlite3.Row db.row_factory = sqlite3.Row
try: try:
sync = db.execute( company = db.execute("SELECT * FROM company_master ORDER BY synced_at DESC LIMIT 1").fetchone()
"SELECT * FROM sync_state ORDER BY last_sync_at DESC LIMIT 1" sync = db.execute("SELECT * FROM sync_state ORDER BY last_sync_at DESC LIMIT 1").fetchone()
).fetchone() tables = [
company = db.execute( "ledger_master","stock_item_master","stock_item_hsn_detail","stock_item_gst_detail",
"SELECT * FROM company_master ORDER BY synced_at DESC LIMIT 1" "voucher","voucher_ledger_entry","inventory_movement",
).fetchone() "group_master","voucher_type_master","stock_group_master","stock_category_master",
"unit_master","cost_centre_master","cost_category_master",
]
counts = {} counts = {}
for table in ( for table in tables:
"ledger_master",
"stock_item_master",
"stock_item_hsn_detail",
"stock_item_gst_detail",
"voucher",
"voucher_ledger_entry",
"inventory_movement",
):
try: try:
counts[table] = int(db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) counts[table] = int(db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
except sqlite3.Error: except sqlite3.Error:
counts[table] = 0 counts[table] = 0
unbalanced = 0
try: try:
unbalanced = int( unbalanced = int(db.execute(
db.execute( "SELECT COUNT(*) FROM voucher_balance_check WHERE ABS(COALESCE(difference,0))>0.01"
"SELECT COUNT(*) FROM voucher_balance_check " ).fetchone()[0])
"WHERE ABS(COALESCE(difference,0)) > 0.01"
).fetchone()[0]
)
except sqlite3.Error: except sqlite3.Error:
pass unbalanced = 0
return { return {
"ready": True, "ready": True, "path": str(db_path), "size_bytes": db_path.stat().st_size,
"path": str(db_path),
"size_bytes": db_path.stat().st_size,
"company": dict(company) if company else {}, "company": dict(company) if company else {},
"sync": dict(sync) if sync else {}, "sync": dict(sync) if sync else {}, "counts": counts,
"counts": counts,
"unbalanced_vouchers": unbalanced, "unbalanced_vouchers": unbalanced,
} }
finally: finally:
db.close() 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]: def status(self, client_id: int) -> dict[str, Any]:
paths = self.paths(client_id) paths = self.paths(client_id)
return { return {
@@ -173,344 +217,219 @@ class AccountingMirrorEngine:
"tdl_exists": paths.tdl_file.is_file(), "tdl_exists": paths.tdl_file.is_file(),
"extractor_ready": paths.extractor_exe.is_file(), "extractor_ready": paths.extractor_exe.is_file(),
"accounting_db_path": str(paths.accounting_db), "accounting_db_path": str(paths.accounting_db),
"mirror_db_path": str(paths.mirror_db), "mirror_db_path": str(paths.current_mirror),
"mirror": self._database_summary(paths.mirror_db), "mirror": self._summary(paths.current_mirror),
} }
def sync( def sync(self, *, client_id: int, expected_company_name: str = "", expected_company_guid: str = "",
self, dsn: str = DEFAULT_DSN, timeout_seconds: int = 300) -> dict[str, Any]:
*,
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) 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) self._ensure_extractor(paths)
shutil.rmtree(paths.work_root, ignore_errors=True) shutil.rmtree(paths.work_root, ignore_errors=True)
paths.work_root.mkdir(parents=True, exist_ok=True) paths.work_root.mkdir(parents=True, exist_ok=True)
output_dir = paths.work_root / "mirror_output_v3_0_1" output_dir = paths.work_root / "mirror_output_v3_1"
cmd = [
str(paths.extractor_exe),
"--dsn",
str(dsn or DEFAULT_DSN),
"--out",
str(output_dir),
]
self.logger.info(
"Accounting Mirror extraction starting client_id=%s expected_company=%s",
client_id,
expected_company_name,
)
try:
cp = subprocess.run( cp = subprocess.run(
cmd, [str(paths.extractor_exe), "--dsn", dsn or DEFAULT_DSN, "--out", str(output_dir)],
capture_output=True, capture_output=True, text=True, timeout=max(60, int(timeout_seconds))
text=True,
timeout=max(30, int(timeout_seconds)),
) )
except subprocess.TimeoutExpired as exc: log = ((cp.stdout or "") + "\n" + (cp.stderr or "")).strip()
raise RuntimeError( (paths.work_root / "extractor.log").write_text(log, encoding="utf-8")
f"Accounting Mirror ODBC extraction timed out after {timeout_seconds} seconds."
) from exc
extraction_log = ((cp.stdout or "") + "\n" + (cp.stderr or "")).strip()
(paths.work_root / "extractor.log").write_text(extraction_log, encoding="utf-8")
if cp.returncode != 0: 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) shutil.copy2(paths.loader_file, loader_copy)
loader = subprocess.run( lp = subprocess.run(
[ [sys.executable, str(loader_copy), "--source", output_dir.name],
sys.executable, cwd=str(paths.work_root), capture_output=True, text=True,
str(loader_copy), timeout=max(60, int(timeout_seconds))
"--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() llog = ((lp.stdout or "") + "\n" + (lp.stderr or "")).strip()
(paths.work_root / "loader.log").write_text(loader_log, encoding="utf-8") (paths.work_root / "loader.log").write_text(llog, encoding="utf-8")
if loader.returncode != 0: if lp.returncode != 0:
raise RuntimeError("Accounting Mirror SQLite build failed. " + loader_log[-4000:]) 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" candidate = output_dir / "accounting_mirror_v3_0_1.act"
if not candidate.is_file(): 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) summary = self._summary(candidate)
company = candidate_summary.get("company") or {} company = summary.get("company") or {}
actual_name = _text(company.get("company_name")) actual_name = _text(company.get("company_name"))
actual_guid = _text(company.get("company_guid")) actual_guid = _text(company.get("company_guid"))
exp_name = _text(expected_company_name) exp_name = _text(expected_company_name)
exp_guid = _text(expected_company_guid) 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: if exp_guid and actual_guid and exp_guid != actual_guid:
raise RuntimeError( raise RuntimeError(f"Tally company GUID mismatch. ERP expected {exp_guid}, ODBC returned {actual_guid}.")
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()
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) src = sqlite3.connect(candidate, timeout=60)
dst = sqlite3.connect(temp_target, timeout=60) dst = sqlite3.connect(temp, timeout=60)
try: try:
src.backup(dst) src.backup(dst); dst.commit()
dst.commit()
finally: finally:
dst.close() dst.close(); src.close()
src.close() temp.replace(paths.current_mirror)
temp_target.replace(paths.mirror_db)
summary = self._database_summary(paths.mirror_db) cache = self._cache_file(paths, actual_name, actual_guid)
self.logger.info( shutil.copy2(paths.current_mirror, cache)
"Accounting Mirror sync completed client_id=%s company=%s vouchers=%s",
client_id,
actual_name,
(summary.get("counts") or {}).get("voucher"),
)
return { return {
"ok": True, "ok": True, "synced_at_utc": _utc_now(), "company_name": actual_name,
"synced_at_utc": _utc_now(), "company_guid": actual_guid, "company_cache_path": str(cache),
"extractor_log_tail": extraction_log[-2000:], "extractor_log_tail": log[-1500:], "loader_log_tail": llog[-1500:],
"loader_log_tail": loader_log[-2000:],
**self.status(client_id), **self.status(client_id),
} }
def _connect(self, client_id: int) -> sqlite3.Connection: def company_db(self, client_id: int, company_name: str, company_guid: str = "") -> Path:
path = self.paths(client_id).mirror_db paths = self.paths(client_id)
if not path.is_file(): 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( raise RuntimeError(
"Accounting Mirror is not available for this client/FY. Run Mirror Sync first." 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."
) )
db = sqlite3.connect(path, timeout=30)
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.row_factory = sqlite3.Row
db.execute("PRAGMA query_only=ON") db.execute("PRAGMA query_only=ON")
return db return db
@staticmethod def master_snapshot(self, client_id: int, company_name: str, company_guid: str = "") -> dict[str, list[dict]]:
def _rows(db: sqlite3.Connection, sql: str, params=()) -> list[dict[str, Any]]: db = self.open_db(client_id, company_name, company_guid)
return [dict(row) for row in db.execute(sql, params).fetchall()]
def query(self, client_id: int, query_name: str, filters: dict[str, Any] | None = None) -> dict[str, Any]:
filters = filters or {}
name = _text(query_name).lower()
limit = max(1, min(5000, int(filters.get("limit") or 500)))
from_date = _text(filters.get("from_date"))
to_date = _text(filters.get("to_date"))
db = self._connect(client_id)
try: try:
if name == "summary": def rows(sql, params=()):
return self._database_summary(self.paths(client_id).mirror_db) return [dict(r) for r in db.execute(sql, params).fetchall()]
ledgers = []
if name == "daybook": for r in rows("SELECT * FROM ledger_master ORDER BY ledger_name"):
where = ["1=1"] ledgers.append({
params: list[Any] = [] "guid": r.get("ledger_guid") or "", "name": r.get("ledger_name") or "",
if from_date: "parent": r.get("parent_group") or "", "opening_balance": float(r.get("opening_balance") or 0),
where.append("date >= ?"); params.append(from_date) "closing_balance": float(r.get("closing_balance") or 0), "is_revenue": r.get("is_revenue") or "",
if to_date: "gst_registration_type": r.get("gst_registration_type") or "",
where.append("date <= ?"); params.append(to_date) "gst_applicable": "", "gst_type_of_supply": "", "hsn_code": "",
rows = self._rows( "is_active": "Yes",
db, })
"SELECT * FROM daybook_flat WHERE " + " AND ".join(where) stock_items = []
+ " ORDER BY date,voucher_type,voucher_number LIMIT ?", for r in rows("SELECT * FROM stock_item_master ORDER BY product_name"):
(*params, limit), stock_items.append({
) "guid": r.get("stock_item_guid") or "", "name": r.get("product_name") or "",
return {"rows": rows, "count": len(rows)} "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),
if name == "trial_balance": "opening_rate": "", "gst_applicable": r.get("gst_applicable") or "",
rows = self._rows( "gst_type_of_supply": r.get("gst_type_of_supply") or "", "hsn_code": r.get("current_hsn") or "",
db, "is_active": "Yes",
""" })
SELECT def aux(table):
lm.ledger_guid, try:
lm.ledger_name, return rows(f"SELECT * FROM {table} ORDER BY name")
lm.parent_group, except sqlite3.Error:
COALESCE(lm.opening_balance,0) AS opening_balance, return []
ROUND(COALESCE(SUM(CASE WHEN vle.dr_cr='DR' THEN vle.amount ELSE 0 END),0),2) AS debit, groups = [{
ROUND(COALESCE(SUM(CASE WHEN vle.dr_cr='CR' THEN vle.amount ELSE 0 END),0),2) AS credit, "guid":r.get("master_guid") or "", "name":r.get("name") or "", "parent":r.get("parent") or "",
ROUND(COALESCE(lm.opening_balance,0) "reserved_name":r.get("reserved_name") or "", "is_revenue":r.get("is_revenue") or "",
+ COALESCE(SUM(CASE WHEN vle.dr_cr='CR' THEN vle.amount ELSE 0 END),0) "is_deemed_positive":r.get("is_deemed_positive") or "", "is_active":r.get("is_active") or "",
- COALESCE(SUM(CASE WHEN vle.dr_cr='DR' THEN vle.amount ELSE 0 END),0),2) AS closing_balance } for r in aux("group_master")]
FROM ledger_master lm voucher_types = [{
LEFT JOIN voucher_ledger_entry vle "guid":r.get("master_guid") or "", "name":r.get("name") or "", "parent":r.get("parent") or "",
ON vle.ledger_name = lm.ledger_name "numbering_method":r.get("numbering_method") or "", "is_active":r.get("is_active") or "",
GROUP BY lm.ledger_guid,lm.ledger_name,lm.parent_group,lm.opening_balance } for r in aux("voucher_type_master")]
ORDER BY lm.parent_group,lm.ledger_name stock_groups = [{
LIMIT ? "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 "",
(limit,), "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")]
return {"rows": rows, "count": len(rows)} 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 ""}
if name == "ledger_transactions": for r in aux("stock_category_master")]
ledger = _text(filters.get("ledger_name")) units = [{"guid":r.get("master_guid") or "", "name":r.get("name") or "",
if not ledger: "original_name":r.get("original_name") or "", "is_simple_unit":r.get("is_simple_unit") or "",
raise ValueError("ledger_name is required.") "base_units":r.get("base_units") or "", "additional_units":r.get("additional_units") or "",
where = ["ledger_name = ?"] "conversion":float(r.get("conversion") or 0)} for r in aux("unit_master")]
params: list[Any] = [ledger] cost_centres = [{"guid":r.get("master_guid") or "", "name":r.get("name") or "",
if from_date: "parent":r.get("parent") or "", "is_active":r.get("is_active") or ""}
where.append("voucher_date >= ?"); params.append(from_date) for r in aux("cost_centre_master")]
if to_date: cost_categories = [{"guid":r.get("master_guid") or "", "name":r.get("name") or "",
where.append("voucher_date <= ?"); params.append(to_date) "parent":r.get("parent") or "", "is_active":r.get("is_active") or ""}
rows = self._rows( for r in aux("cost_category_master")]
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 { return {
"voucher": dict(voucher) if voucher else None, "groups": groups, "ledgers": ledgers, "voucher_types": voucher_types,
"ledger_entries": ledger_rows, "stock_groups": stock_groups, "stock_categories": stock_categories,
"inventory_entries": inventory_rows, "stock_items": stock_items, "units": units, "cost_centres": cost_centres,
"cost_categories": cost_categories,
} }
finally:
if name == "exceptions": db.close()
rows = self._rows(
db, def transactions(self, client_id: int, company_name: str, date_from: str, date_to: str,
""" company_guid: str = "") -> dict[str, Any]:
SELECT * FROM voucher_balance_check db = self.open_db(client_id, company_name, company_guid)
WHERE ABS(COALESCE(difference,0)) > 0.01 try:
ORDER BY ABS(difference) DESC LIMIT ? vouchers = []
""", for v in db.execute(
(limit,), """SELECT * FROM voucher WHERE voucher_date>=? AND voucher_date<=?
) ORDER BY voucher_date,voucher_type,voucher_number""",
return {"unbalanced_vouchers": rows, "count": len(rows)} (date_from, date_to),
).fetchall():
raise ValueError( guid = v["voucher_guid"]
"Unsupported mirror query. Supported: summary, daybook, trial_balance, " ledger_entries = []
"ledger_transactions, inventory, stock_items, hsn_history, gst_history, " for e in db.execute(
"cash_transactions, voucher, exceptions." "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: finally:
db.close() db.close()
@@ -11,9 +11,8 @@ from typing import Any
from . import __version__ from . import __version__
from .accounting_store import LocalAccountingStore from .accounting_store import LocalAccountingStore
from .tally import TallyLiveConnector from .mirror_tally import MirrorFirstTallyConnector
from .native_voucher_engine import NativeVoucherEngine from .native_voucher_engine import NativeVoucherEngine
from .accounting_mirror import AccountingMirrorEngine
_CASH_TALLY_EXTRACTION_LOCK = threading.Lock() _CASH_TALLY_EXTRACTION_LOCK = threading.Lock()
@@ -66,8 +65,7 @@ class AgentCommandProcessor:
self.config = config self.config = config
self.logger = logger self.logger = logger
self.store = LocalAccountingStore(config.storage_root) self.store = LocalAccountingStore(config.storage_root)
self.tally = TallyLiveConnector() self.tally = MirrorFirstTallyConnector(self.store, logger)
self.mirror = AccountingMirrorEngine(self.store, logger)
def process(self, command: dict[str, Any]) -> dict[str, Any]: def process(self, command: dict[str, Any]) -> dict[str, Any]:
command_id = str(command.get("command_id") or "").strip() command_id = str(command.get("command_id") or "").strip()
@@ -164,8 +162,10 @@ class AgentCommandProcessor:
client_id = payload.get("client_id") client_id = payload.get("client_id")
relative_dir = str(payload.get("accounting_relative_dir") or "").strip() relative_dir = str(payload.get("accounting_relative_dir") or "").strip()
if client_id in (None, "") or not relative_dir: if client_id in (None, "") or not relative_dir:
self.tally.bind_context(None, payload)
return return
self.store.bind_client_path(int(client_id), relative_dir) 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]: def _agent_info(self) -> dict[str, Any]:
return { return {
@@ -173,6 +173,8 @@ class AgentCommandProcessor:
"tally_capability": True, "accounting_act_capability": True, "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_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, "tally_transaction_sync_capability": True,
"dotnet_odbc_mirror_capability": True,
"mirror_first_accounting_reads": True,
"historical_learning_read_capability": True, "historical_learning_read_capability": True,
"purchase_posting_preflight_capability": True, "purchase_posting_preflight_capability": True,
"purchase_voucher_write_capability": True, "purchase_voucher_write_capability": True,
@@ -277,6 +279,7 @@ class AgentCommandProcessor:
if not company: 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.") 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() 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) masters = self.tally.fetch_accounting_masters(company_name)
self.store.record_tally_status(client_id, tally_status) 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) 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: 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.") 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() 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) transactions = self.tally.fetch_accounting_transactions(company_name, date_from, date_to)
self.store.record_tally_status(client_id, tally_status) self.store.record_tally_status(client_id, tally_status)
sync = self.store.replace_transaction_snapshot( sync = self.store.replace_transaction_snapshot(
@@ -388,7 +392,9 @@ class AgentCommandProcessor:
or "" or ""
).strip() ).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( transactions = self.tally.fetch_accounting_transactions(
company_name, company_name,
date_from, 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)) 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."}) 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)}) 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) 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 []} 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} 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 []), lines=list(payload.get("lines") or []),
items=list(payload.get("items") or []), items=list(payload.get("items") or []),
) )
self.tally.refresh_mirror(company_name, str(company.get("guid") or ""))
safe_result = { safe_result = {
key: value key: value
for key, value in result.items() for key, value in result.items()
@@ -2710,10 +2718,20 @@ class AgentCommandProcessor:
fy_start = str(payload.get("fy_start") or "").strip() fy_start = str(payload.get("fy_start") or "").strip()
fy_end = str(payload.get("fy_end") or "").strip() fy_end = str(payload.get("fy_end") or "").strip()
if not tally_guid: raise ValueError("Select a mapped Tally company for depreciation.") 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()} 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]: def _calculate_it_depreciation(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id")) 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( result = self.store.calculate_it_depreciation(
client_id, client_id,
tally_guid=str(payload.get("tally_guid") or "").strip(), tally_guid=str(payload.get("tally_guid") or "").strip(),
@@ -2805,45 +2823,104 @@ class AgentCommandProcessor:
client_id = int(payload.get("client_id") or 0) client_id = int(payload.get("client_id") or 0)
if client_id <= 0: if client_id <= 0:
raise ValueError("client_id is required.") 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]: def _accounting_mirror_sync(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id") or 0) client_id = int(payload.get("client_id") or 0)
if client_id <= 0: if client_id <= 0:
raise ValueError("client_id is required.") raise ValueError("client_id is required.")
company_name = str(payload.get("company_name") or payload.get("expected_company_name") or "").strip()
expected_company_name = str( company_guid = str(payload.get("company_guid") or payload.get("tally_guid") or "").strip()
payload.get("company_name") self.tally.bind_context(client_id, payload)
or payload.get("tally_company_name") return self.tally.refresh_mirror(company_name, company_guid, int(payload.get("timeout_seconds") or 300))
or payload.get("expected_company_name")
or ""
).strip()
expected_company_guid = str(
payload.get("company_guid")
or payload.get("tally_guid")
or payload.get("expected_company_guid")
or ""
).strip()
# The extractor reads the active Tally company/period. The produced
# SQLite mirror is verified against the ERP-selected company before
# the current mirror database is replaced.
return self.mirror.sync(
client_id=client_id,
expected_company_name=expected_company_name,
expected_company_guid=expected_company_guid,
dsn=str(payload.get("dsn") or "TallyODBC64_9000").strip(),
timeout_seconds=int(payload.get("timeout_seconds") or 300),
)
def _accounting_mirror_query(self, payload: dict[str, Any]) -> dict[str, Any]: def _accounting_mirror_query(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id") or 0) client_id = int(payload.get("client_id") or 0)
if client_id <= 0: if client_id <= 0:
raise ValueError("client_id is required.") raise ValueError("client_id is required.")
query_name = str(payload.get("query") or payload.get("query_name") or "").strip() self.tally.bind_context(client_id, payload)
if not query_name: query = str(payload.get("query") or payload.get("query_name") or "").strip().lower()
raise ValueError("query is required.")
filters = payload.get("filters") or {} filters = payload.get("filters") or {}
if not isinstance(filters, dict): company_name = str(payload.get("company_name") or filters.get("company_name") or "").strip()
raise ValueError("filters must be an object.") tally_guid = str(payload.get("tally_guid") or filters.get("tally_guid") or "").strip()
return self.mirror.query(client_id, query_name, filters) 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()
@@ -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
@@ -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<string>();
for (int i=0; i<r.FieldCount; i++) headers.Add(r.GetName(i));
using (var w = new StreamWriter(path,false,new UTF8Encoding(true))) {
w.WriteLine(String.Join(",",headers.ConvertAll(Csv)));
while (r.Read()) {
rows++;
var vals = new List<string>();
for (int i=0; i<r.FieldCount; i++) vals.Add(Safe(r.GetValue(i)));
w.WriteLine(String.Join(",",vals.ConvertAll(Csv)));
if (rows <= 2)
Console.WriteLine(label+" ROW "+rows+" | "+String.Join(" | ",vals));
}
}
}
}
Console.WriteLine(label+" - ROWS READ: "+rows);
Console.WriteLine(label+" - CSV: "+Path.GetFullPath(path));
Console.Out.Flush();
}
static void TryExtract(OdbcConnection cn, string label, string sql, string path,
out int rows, out double sec)
{
try
{
Extract(cn, label, sql, path, out rows, out sec);
}
catch (Exception ex)
{
rows = 0;
sec = 0;
Console.WriteLine(label + " - OPTIONAL QUERY FAILED: " + ex.Message);
using (var w = new StreamWriter(path, false, new UTF8Encoding(true)))
{
w.WriteLine("");
}
}
}
static int Main(string[] args)
{
string dsn="TallyODBC64_9000", outDir="mirror_output_v3_1";
for(int i=0;i<args.Length;i++) {
if(args[i]=="--dsn" && i+1<args.Length) dsn=args[++i];
else if(args[i]=="--out" && i+1<args.Length) outDir=args[++i];
}
Directory.CreateDirectory(outDir);
try {
var total=Stopwatch.StartNew();
Console.WriteLine("ERP ACCOUNTING MIRROR v3.1 - FULL ACCOUNTING READ MIRROR");
Console.WriteLine("READ ONLY | ODBC | NO XML | NO TALLY WRITES");
Console.WriteLine("BEFORE CONNECT");
using(var cn=new OdbcConnection("DSN="+dsn+";")) {
cn.Open();
Console.WriteLine("CONNECTED");
int c,l,s,tax,v,vl,im;
double sc,sl,ss,stax,sv,svl,sim;
Extract(cn,"COMPANY",
"SELECT $Name,$GUID,$AlterID,$StartingFrom,$BooksFrom FROM ERPAMCompanyInfo",
Path.Combine(outDir,"company.csv"),out c,out sc);
Extract(cn,"LEDGER MASTER",
"SELECT $GUID,$AlterID,$Name,$Parent,$OpeningBalance,$ClosingBalance,$IsRevenue,$PartyGSTIN,$GSTRegistrationType FROM ERPAMLedgerMaster",
Path.Combine(outDir,"ledger_master.csv"),out l,out sl);
Extract(cn,"STOCK MASTER",
"SELECT $GUID,$AlterID,$Name,$Parent,$BaseUnits,$OpeningBalance,$OpeningValue,$GSTApplicable,$GSTRepUoM,$GSTTypeofSupply,$GSTItemHSNCodeEx FROM ERPAMStockMaster",
Path.Combine(outDir,"stock_master.csv"),out s,out ss);
int grp, vt, sg, sc, un, cc, ccat;
double sgrp, svt, ssg, ssc, sun, scc, sccat;
TryExtract(cn,"GROUP MASTER",
"SELECT $GUID,$Name,$Parent,$ReservedName,$IsRevenue,$IsDeemedPositive,$IsActive FROM ERPAMGroupMaster",
Path.Combine(outDir,"group_master.csv"),out grp,out sgrp);
TryExtract(cn,"VOUCHER TYPE MASTER",
"SELECT $GUID,$Name,$Parent,$NumberingMethod,$IsActive FROM ERPAMVoucherTypeMaster",
Path.Combine(outDir,"voucher_type_master.csv"),out vt,out svt);
TryExtract(cn,"STOCK GROUP MASTER",
"SELECT $GUID,$Name,$Parent,$BaseUnits,$GSTApplicable,$GSTTypeofSupply,$IsActive FROM ERPAMStockGroupMaster",
Path.Combine(outDir,"stock_group_master.csv"),out sg,out ssg);
TryExtract(cn,"STOCK CATEGORY MASTER",
"SELECT $GUID,$Name,$Parent,$IsActive FROM ERPAMStockCategoryMaster",
Path.Combine(outDir,"stock_category_master.csv"),out sc,out ssc);
TryExtract(cn,"UNIT MASTER",
"SELECT $GUID,$Name,$OriginalName,$IsSimpleUnit,$BaseUnits,$AdditionalUnits,$Conversion FROM ERPAMUnitMaster",
Path.Combine(outDir,"unit_master.csv"),out un,out sun);
TryExtract(cn,"COST CENTRE MASTER",
"SELECT $GUID,$Name,$Parent,$IsActive FROM ERPAMCostCentreMaster",
Path.Combine(outDir,"cost_centre_master.csv"),out cc,out scc);
TryExtract(cn,"COST CATEGORY MASTER",
"SELECT $GUID,$Name,$Parent,$IsActive FROM ERPAMCostCategoryMaster",
Path.Combine(outDir,"cost_category_master.csv"),out ccat,out sccat);
Extract(cn,"STOCK HSN MASTER",
"SELECT $Name,$GUID,$AlterID,$Parent,$BaseUnits,$GSTApplicable,$GSTTypeofSupply,"+
"$HSNSource1,$HSNApplicableFrom1,$HSNCode1,$HSNValue1,$HSNDescription1,"+
"$HSNSource2,$HSNApplicableFrom2,$HSNCode2,$HSNValue2,$HSNDescription2,"+
"$HSNSource3,$HSNApplicableFrom3,$HSNCode3,$HSNValue3,$HSNDescription3,"+
"$HSNSource4,$HSNApplicableFrom4,$HSNCode4,$HSNValue4,$HSNDescription4,"+
"$HSNSource5,$HSNApplicableFrom5,$HSNCode5,$HSNValue5,$HSNDescription5 "+
"FROM ERPAMStockHSNExport",
Path.Combine(outDir,"stock_hsn_master.csv"),out tax,out stax);
int gstRows; double gstSec;
Extract(cn,"STOCK GST MASTER",
"SELECT $Name,$GUID,$AlterID,$Parent,$BaseUnits,$GSTApplicable,$GSTTypeofSupply,$GSTRepUoM,"+
"$GSTApplicableFrom,$GSTSource,$GSTTaxability,$GSTSupplyType,$GSTReportingUOM,$GSTStateName,"+
"$GSTRateDutyHead1,$GSTRate1,$GSTValuationType1,$GSTRatePerUnit1,"+
"$GSTRateDutyHead2,$GSTRate2,$GSTValuationType2,$GSTRatePerUnit2,"+
"$GSTRateDutyHead3,$GSTRate3,$GSTValuationType3,$GSTRatePerUnit3,"+
"$GSTRateDutyHead4,$GSTRate4,$GSTValuationType4,$GSTRatePerUnit4 "+
"FROM ERPAMStockGSTExport",
Path.Combine(outDir,"stock_gst_master.csv"),out gstRows,out gstSec);
Extract(cn,"VOUCHER HEADER",
"SELECT $VoucherGUID,$VoucherAlterID,$VoucherMasterID,$VoucherDate,$VoucherType,$VoucherNumber,$PartyLedger,$VoucherNarration,$VoucherReference,$VoucherAmount,$IsCancelled,$IsOptional FROM ERPAMVoucherHeader",
Path.Combine(outDir,"voucher_header.csv"),out v,out sv);
Extract(cn,"VOUCHER LEDGER",
"SELECT $VoucherGUID,$VoucherAlterID,$VoucherMasterID,$VoucherDate,$VoucherType,$VoucherNumber,$PartyLedger,$LedgerName,$IsDeemedPositive,$LedgerAmount FROM ERPAMVoucherLedger",
Path.Combine(outDir,"voucher_ledger.csv"),out vl,out svl);
Extract(cn,"INVENTORY",
"SELECT $VoucherGUID,$VoucherAlterID,$VoucherMasterID,$VoucherDate,$VoucherType,$VoucherNumber,$PartyLedger,$StockItemName,$HSNCode,$BilledQty,$ActualQty,$Rate,$IsDeemedPositive,$InventoryAmount FROM ERPAMInventoryMovement",
Path.Combine(outDir,"inventory_movement.csv"),out im,out sim);
Console.WriteLine("SUMMARY | Company="+c+
" | Ledger="+l+
" | Stock="+s+
" | Groups="+grp+" | VoucherTypes="+vt+" | StockGroups="+sg+" | StockCategories="+sc+" | Units="+un+" | CostCentres="+cc+" | CostCategories="+ccat+" | StockHSN="+tax+" | StockGST="+gstRows+
" | Voucher="+v+
" | VoucherLedger="+vl+
" | Inventory="+im+
" | TotalSeconds="+total.Elapsed.TotalSeconds.ToString("0.000"));
}
Console.WriteLine("SUCCESS");
return 0;
}
catch(Exception ex) {
Console.WriteLine("ERROR TYPE: "+ex.GetType().FullName);
Console.WriteLine("ERROR: "+ex.Message);
if(ex.InnerException!=null) Console.WriteLine("INNER: "+ex.InnerException.Message);
return 2;
}
}
}
@@ -0,0 +1,612 @@
from pathlib import Path
import argparse, csv, hashlib, re, sqlite3
from datetime import datetime
ROOT = Path(__file__).resolve().parent
def ck(k): return (k or "").strip().lstrip("$").strip()
def norm(row): return {ck(k):(v or "") for k,v in row.items()}
def t(v): return (v or "").replace("\x04","").strip()
def num(v):
s=t(v).replace(",","")
m=re.search(r"[-+]?\d+(?:\.\d+)?",s)
return float(m.group(0)) if m else None
def integer(v):
x=num(v)
return int(x) if x is not None else None
def yes(v):
return 1 if t(v).lower() in {"yes","true","1"} else 0
def date_iso(v):
s=t(v)
if not s: return None
for fmt in ("%Y%m%d","%m/%d/%Y %H:%M:%S","%d-%b-%y","%d-%b-%Y",
"%d/%m/%Y","%m/%d/%Y","%Y-%m-%d"):
try:
return datetime.strptime(s,fmt).date().isoformat()
except ValueError:
pass
return s
def unit_from_qty(v):
s=t(v)
m=re.search(r"[-+]?\d+(?:\.\d+)?\s*(.+)$",s)
return m.group(1).strip() if m else ""
def csv_rows(src, name):
p=src/name
if not p.exists():
raise SystemExit(f"Missing {p}. Run run_v3_0.ps1 first.")
with p.open("r",encoding="utf-8-sig",newline="") as f:
r=csv.DictReader(f)
print(f"{name} headers:",[ck(x) for x in (r.fieldnames or [])])
for row in r:
yield norm(row)
def company_instance_id(guid, name, starting_from, books_from):
raw="|".join([t(guid),t(name),t(starting_from),t(books_from)]).encode("utf-8")
return hashlib.sha256(raw).hexdigest()[:24]
def applicable(rows, target_date):
"""Latest effective row whose applicable_from <= target_date.
If target date is absent, use latest dated row."""
if not rows:
return None
dated=[r for r in rows if r.get("applicable_from")]
if target_date:
eligible=[r for r in dated if r["applicable_from"] <= target_date]
if eligible:
return max(eligible,key=lambda x:x["applicable_from"])
if dated:
return max(dated,key=lambda x:x["applicable_from"])
return rows[-1]
def main():
ap=argparse.ArgumentParser()
ap.add_argument("--source", default="mirror_output_v3_0_1")
args=ap.parse_args()
src=ROOT/args.source
db=src/"accounting_mirror_v3_0_1.act"
src.mkdir(parents=True,exist_ok=True)
con=sqlite3.connect(db)
con.execute("PRAGMA journal_mode=WAL")
con.execute("PRAGMA synchronous=NORMAL")
con.execute("PRAGMA foreign_keys=ON")
con.executescript("""
CREATE TABLE IF NOT EXISTS company_master(
company_instance_id TEXT PRIMARY KEY,
company_guid TEXT,
company_name TEXT NOT NULL,
alter_id INTEGER,
starting_from TEXT,
books_from TEXT,
synced_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ledger_master(
company_instance_id TEXT NOT NULL,
company_guid TEXT,
ledger_guid TEXT NOT NULL,
alter_id INTEGER,
ledger_name TEXT NOT NULL,
parent_group TEXT,
opening_balance REAL,
closing_balance REAL,
is_revenue INTEGER NOT NULL DEFAULT 0,
party_gstin TEXT,
gst_registration_type TEXT,
synced_at TEXT NOT NULL,
PRIMARY KEY(company_instance_id,ledger_guid)
);
CREATE TABLE IF NOT EXISTS stock_item_master(
company_instance_id TEXT NOT NULL,
company_guid TEXT,
stock_item_guid TEXT NOT NULL,
alter_id INTEGER,
product_name TEXT NOT NULL,
parent_group TEXT,
base_uom TEXT,
opening_qty_text TEXT,
opening_qty REAL,
opening_value REAL,
gst_reporting_uom TEXT,
gst_type_of_supply TEXT,
gst_applicable TEXT,
current_hsn TEXT,
current_gst_rate REAL,
synced_at TEXT NOT NULL,
PRIMARY KEY(company_instance_id,stock_item_guid)
);
CREATE TABLE IF NOT EXISTS stock_item_hsn_detail(
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_instance_id TEXT NOT NULL,
company_guid TEXT,
stock_item_guid TEXT NOT NULL,
stock_item_name TEXT NOT NULL,
history_position INTEGER NOT NULL,
applicable_from TEXT,
hsn_source TEXT,
hsn_code TEXT,
hsn_value TEXT,
effective_hsn TEXT,
description TEXT,
synced_at TEXT NOT NULL,
UNIQUE(company_instance_id,stock_item_guid,history_position)
);
CREATE TABLE IF NOT EXISTS stock_item_gst_detail(
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_instance_id TEXT NOT NULL,
company_guid TEXT,
stock_item_guid TEXT NOT NULL,
stock_item_name TEXT NOT NULL,
history_position INTEGER NOT NULL,
applicable_from TEXT,
gst_source TEXT,
taxability TEXT,
supply_type TEXT,
reporting_uom TEXT,
state_name TEXT,
cgst_rate REAL,
sgst_rate REAL,
igst_rate REAL,
cess_rate REAL,
total_gst_rate REAL,
synced_at TEXT NOT NULL,
UNIQUE(company_instance_id,stock_item_guid,history_position)
);
CREATE TABLE IF NOT EXISTS stock_item_gst_rate_detail(
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_instance_id TEXT NOT NULL,
company_guid TEXT,
stock_item_guid TEXT NOT NULL,
stock_item_name TEXT NOT NULL,
gst_history_position INTEGER NOT NULL,
rate_position INTEGER NOT NULL,
applicable_from TEXT,
state_name TEXT,
duty_head TEXT,
gst_rate REAL,
valuation_type TEXT,
rate_per_unit REAL,
synced_at TEXT NOT NULL,
UNIQUE(company_instance_id,stock_item_guid,gst_history_position,rate_position)
);
CREATE TABLE IF NOT EXISTS voucher(
company_instance_id TEXT NOT NULL,
company_guid TEXT,
voucher_guid TEXT NOT NULL,
alter_id INTEGER,
master_id INTEGER,
voucher_date TEXT,
voucher_type TEXT,
voucher_number TEXT,
party_ledger TEXT,
narration TEXT,
reference TEXT,
voucher_amount REAL,
is_cancelled INTEGER NOT NULL DEFAULT 0,
is_optional INTEGER NOT NULL DEFAULT 0,
synced_at TEXT NOT NULL,
PRIMARY KEY(company_instance_id,voucher_guid)
);
CREATE TABLE IF NOT EXISTS voucher_ledger_entry(
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_instance_id TEXT NOT NULL,
company_guid TEXT,
voucher_guid TEXT NOT NULL,
line_no INTEGER NOT NULL,
alter_id INTEGER,
master_id INTEGER,
voucher_date TEXT,
voucher_type TEXT,
voucher_number TEXT,
party_ledger TEXT,
ledger_name TEXT NOT NULL,
dr_cr TEXT NOT NULL,
amount REAL,
raw_amount REAL,
raw_is_deemed_positive TEXT,
UNIQUE(company_instance_id,voucher_guid,line_no)
);
CREATE TABLE IF NOT EXISTS inventory_movement(
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_instance_id TEXT NOT NULL,
company_guid TEXT,
voucher_guid TEXT NOT NULL,
line_no INTEGER NOT NULL,
alter_id INTEGER,
master_id INTEGER,
voucher_date TEXT,
voucher_type TEXT,
voucher_number TEXT,
party_ledger TEXT,
stock_item_name TEXT NOT NULL,
stock_item_guid TEXT,
hsn TEXT,
gst_rate REAL,
cgst_rate REAL,
sgst_rate REAL,
igst_rate REAL,
cess_rate REAL,
taxability TEXT,
billed_qty_text TEXT,
billed_qty REAL,
actual_qty_text TEXT,
actual_qty REAL,
uom TEXT,
rate_text TEXT,
rate REAL,
value REAL,
direction TEXT,
signed_qty REAL,
signed_value REAL,
raw_is_deemed_positive TEXT,
UNIQUE(company_instance_id,voucher_guid,line_no)
);
CREATE TABLE IF NOT EXISTS opening_stock(
company_instance_id TEXT NOT NULL,
company_guid TEXT,
stock_item_guid TEXT NOT NULL,
stock_item_name TEXT NOT NULL,
hsn TEXT,
gst_rate REAL,
uom TEXT,
opening_qty REAL,
opening_value REAL,
source_type TEXT NOT NULL DEFAULT 'OPENING_BALANCE',
PRIMARY KEY(company_instance_id,stock_item_guid)
);
CREATE TABLE IF NOT EXISTS sync_state(
company_instance_id TEXT PRIMARY KEY,
company_guid TEXT,
company_name TEXT,
period_from TEXT,
period_to TEXT,
max_voucher_alter_id INTEGER,
max_ledger_alter_id INTEGER,
max_stock_alter_id INTEGER,
voucher_count INTEGER,
voucher_ledger_count INTEGER,
inventory_count INTEGER,
hsn_history_count INTEGER,
gst_history_count INTEGER,
last_full_sync_at TEXT,
last_sync_at TEXT,
sync_mode TEXT
);
CREATE TABLE IF NOT EXISTS adjustment_queue(
adjustment_id INTEGER PRIMARY KEY AUTOINCREMENT,
company_instance_id TEXT NOT NULL,
company_guid TEXT,
source_voucher_guid TEXT,
adjustment_type TEXT NOT NULL,
proposed_voucher_type TEXT NOT NULL,
voucher_date TEXT NOT NULL,
narration TEXT,
reason TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
created_by TEXT,
approved_by TEXT,
created_at TEXT NOT NULL,
approved_at TEXT,
posted_at TEXT,
tally_response TEXT,
posted_voucher_guid TEXT
);
CREATE TABLE IF NOT EXISTS adjustment_line(
line_id INTEGER PRIMARY KEY AUTOINCREMENT,
adjustment_id INTEGER NOT NULL,
line_no INTEGER NOT NULL,
ledger_name TEXT NOT NULL,
dr_cr TEXT NOT NULL CHECK(dr_cr IN('DR','CR')),
amount REAL NOT NULL,
FOREIGN KEY(adjustment_id) REFERENCES adjustment_queue(adjustment_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_voucher_date ON voucher(company_instance_id,voucher_date);
CREATE INDEX IF NOT EXISTS ix_voucher_alter ON voucher(company_instance_id,alter_id);
CREATE INDEX IF NOT EXISTS ix_vle_ledger ON voucher_ledger_entry(company_instance_id,ledger_name);
CREATE INDEX IF NOT EXISTS ix_inv_item ON inventory_movement(company_instance_id,stock_item_name);
CREATE INDEX IF NOT EXISTS ix_inv_date ON inventory_movement(company_instance_id,voucher_date);
CREATE INDEX IF NOT EXISTS ix_hsn_effective ON stock_item_hsn_detail(company_instance_id,stock_item_guid,applicable_from);
CREATE INDEX IF NOT EXISTS ix_gst_effective ON stock_item_gst_detail(company_instance_id,stock_item_guid,applicable_from);
""")
now=datetime.now().isoformat(timespec="seconds")
company_rows=list(csv_rows(src,"company.csv"))
if not company_rows:
raise SystemExit("No company returned by ODBC.")
c=company_rows[0]
guid=t(c.get("GUID")) or "ACTIVE_COMPANY"
name=t(c.get("Name")) or "Active Tally Company"
starting=date_iso(c.get("StartingFrom"))
books=date_iso(c.get("BooksFrom"))
instance=company_instance_id(guid,name,starting,books)
con.execute("""INSERT OR REPLACE INTO company_master
VALUES(?,?,?,?,?,?,?)""",
(instance,guid,name,integer(c.get("AlterID")),starting,books,now))
for table in ("voucher_ledger_entry","inventory_movement","voucher","opening_stock",
"ledger_master","stock_item_master","stock_item_hsn_detail",
"stock_item_gst_detail","stock_item_gst_rate_detail"):
con.execute(f"DELETE FROM {table} WHERE company_instance_id=?",(instance,))
ledger_count=0; max_ledger_alter=0
for r in csv_rows(src,"ledger_master.csv"):
lg=t(r.get("GUID"))
if not lg: continue
aid=integer(r.get("AlterID")) or 0
max_ledger_alter=max(max_ledger_alter,aid)
con.execute("""INSERT OR REPLACE INTO ledger_master
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
(instance,guid,lg,aid,t(r.get("Name")),t(r.get("Parent")),
num(r.get("OpeningBalance")),num(r.get("ClosingBalance")),yes(r.get("IsRevenue")),
t(r.get("PartyGSTIN")),t(r.get("GSTRegistrationType")),now))
ledger_count+=1
# Read stock tax data first so effective histories are available when stock and
# inventory rows are inserted.
tax_by_name={}
hsn_by_guid={}
gst_by_guid={}
hsn_count=gst_count=0
# HSN history - use only the indexed positions already proven in Tally.
for r in csv_rows(src,"stock_hsn_master.csv"):
sg=t(r.get("GUID"))
sn=t(r.get("Name"))
if not sg or not sn: continue
tax_by_name.setdefault(sn.casefold(),{"guid":sg})
hrows=[]
for i in range(1,6):
d=date_iso(r.get(f"HSNApplicableFrom{i}"))
code=t(r.get(f"HSNCode{i}"))
val=t(r.get(f"HSNValue{i}"))
source=t(r.get(f"HSNSource{i}"))
desc=t(r.get(f"HSNDescription{i}"))
if not any((d,code,val,source,desc)): continue
eff=val or code
row={"history_position":i,"applicable_from":d,"hsn_source":source,
"hsn_code":code,"hsn_value":val,"effective_hsn":eff,"description":desc}
hrows.append(row)
con.execute("""INSERT INTO stock_item_hsn_detail
(company_instance_id,company_guid,stock_item_guid,stock_item_name,history_position,
applicable_from,hsn_source,hsn_code,hsn_value,effective_hsn,description,synced_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
(instance,guid,sg,sn,i,d,source,code,val,eff,desc,now))
hsn_count+=1
hsn_by_guid[sg]=hrows
# GST rate master - exact GSTDetails[1] path already proven in Tally.
for r in csv_rows(src,"stock_gst_master.csv"):
sg=t(r.get("GUID"))
sn=t(r.get("Name"))
if not sg or not sn: continue
tax_by_name.setdefault(sn.casefold(),{"guid":sg})
d=date_iso(r.get("GSTApplicableFrom"))
source=t(r.get("GSTSource"))
tax=t(r.get("GSTTaxability"))
supply=t(r.get("GSTSupplyType"))
rep=t(r.get("GSTReportingUOM"))
state=t(r.get("GSTStateName"))
rates={}
rate_rows=[]
for rp in range(1,5):
duty=t(r.get(f"GSTRateDutyHead{rp}"))
rate=num(r.get(f"GSTRate{rp}"))
valuation=t(r.get(f"GSTValuationType{rp}"))
perunit=num(r.get(f"GSTRatePerUnit{rp}"))
if not any((duty, rate is not None, valuation, perunit is not None)):
continue
rate_rows.append((rp,duty,rate,valuation,perunit))
dk=duty.replace("\x04","").strip().upper()
if dk=="CGST": rates["cgst"]=rate
elif "SGST" in dk or "UTGST" in dk: rates["sgst"]=rate
elif dk=="IGST": rates["igst"]=rate
elif "CESS" in dk: rates["cess"]=rate
con.execute("""INSERT INTO stock_item_gst_rate_detail
(company_instance_id,company_guid,stock_item_guid,stock_item_name,
gst_history_position,rate_position,applicable_from,state_name,duty_head,
gst_rate,valuation_type,rate_per_unit,synced_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(instance,guid,sg,sn,1,rp,d,state,duty,rate,valuation,perunit,now))
if any((d,source,tax,supply,rep,state,rate_rows)):
cg=rates.get("cgst"); ss=rates.get("sgst"); ig=rates.get("igst"); cess=rates.get("cess")
total=ig if ig is not None else ((cg or 0)+(ss or 0) if cg is not None or ss is not None else None)
grow={"history_position":1,"applicable_from":d,"gst_source":source,
"taxability":tax,"supply_type":supply,"reporting_uom":rep,"state_name":state,
"cgst_rate":cg,"sgst_rate":ss,"igst_rate":ig,"cess_rate":cess,"total_gst_rate":total}
gst_by_guid[sg]=[grow]
con.execute("""INSERT INTO stock_item_gst_detail
(company_instance_id,company_guid,stock_item_guid,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,synced_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(instance,guid,sg,sn,1,d,source,tax,supply,rep,state,cg,ss,ig,cess,total,now))
gst_count+=1
else:
gst_by_guid[sg]=[]
stock_by_name={}; stock_count=0; max_stock_alter=0
for r in csv_rows(src,"stock_master.csv"):
sg=t(r.get("GUID"))
if not sg: continue
aid=integer(r.get("AlterID")) or 0
max_stock_alter=max(max_stock_alter,aid)
sn=t(r.get("Name"))
base=t(r.get("BaseUnits"))
rep=t(r.get("GSTRepUoM"))
taxrow=tax_by_name.get(sn.casefold(),{})
hcur=applicable(hsn_by_guid.get(sg,[]), None)
gcur=applicable(gst_by_guid.get(sg,[]), None)
current_hsn=(hcur or {}).get("effective_hsn") or t(r.get("GSTItemHSNCodeEx"))
current_rate=(gcur or {}).get("total_gst_rate")
con.execute("""INSERT OR REPLACE INTO stock_item_master
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(instance,guid,sg,aid,sn,t(r.get("Parent")),base,t(r.get("OpeningBalance")),
num(r.get("OpeningBalance")),num(r.get("OpeningValue")),rep,t(r.get("GSTTypeofSupply")),
t(r.get("GSTApplicable")),current_hsn,current_rate,now))
con.execute("""INSERT OR REPLACE INTO opening_stock
VALUES(?,?,?,?,?,?,?,?,?,?)""",
(instance,guid,sg,sn,current_hsn,current_rate,base,num(r.get("OpeningBalance")),
num(r.get("OpeningValue")),"OPENING_BALANCE"))
stock_by_name[sn.casefold()]={"guid":sg,"uom":base}
stock_count+=1
voucher_count=0; max_voucher_alter=0; voucher_dates=[]
for r in csv_rows(src,"voucher_header.csv"):
vg=t(r.get("VoucherGUID"))
if not vg: continue
aid=integer(r.get("VoucherAlterID")) or 0
max_voucher_alter=max(max_voucher_alter,aid)
d=date_iso(r.get("VoucherDate"))
if d: voucher_dates.append(d)
con.execute("""INSERT OR REPLACE INTO voucher
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(instance,guid,vg,aid,integer(r.get("VoucherMasterID")),d,t(r.get("VoucherType")),
t(r.get("VoucherNumber")),t(r.get("PartyLedger")),t(r.get("VoucherNarration")),
t(r.get("VoucherReference")),num(r.get("VoucherAmount")),yes(r.get("IsCancelled")),
yes(r.get("IsOptional")),now))
voucher_count+=1
line_seq={}; vle_count=0
for r in csv_rows(src,"voucher_ledger.csv"):
vg=t(r.get("VoucherGUID"))
if not vg: continue
line_seq[vg]=line_seq.get(vg,0)+1
raw=num(r.get("LedgerAmount"))
deemed=t(r.get("IsDeemedPositive"))
if raw is not None and raw<0: drcr="DR"
elif raw is not None and raw>0: 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()
@@ -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)
@@ -327,34 +327,6 @@ class TallyLiveConnector:
raw = self._post_xml(xml) raw = self._post_xml(xml)
return self._parse_master_rows(raw, spec["tag"]) 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"""<ENVELOPE>
<HEADER><VERSION>1</VERSION><TALLYREQUEST>Export</TALLYREQUEST><TYPE>Collection</TYPE><ID>ARRRCashInHandLedgers</ID></HEADER>
<BODY><DESC>
<STATICVARIABLES>{self._static_variables(company)}</STATICVARIABLES>
<TDL><TDLMESSAGE>
<COLLECTION NAME="ARRRCashInHandLedgers" ISMODIFY="No">
<TYPE>Ledger</TYPE>
<CHILDOF>Cash-in-Hand</CHILDOF>
<BELONGSTO>Yes</BELONGSTO>
<FETCH>{spec['fetch']}</FETCH>
</COLLECTION>
</TDLMESSAGE></TDL>
</DESC></BODY>
</ENVELOPE>"""
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]]: def fetch_accounting_masters(self, company_name: str) -> dict[str, list[dict]]:
result: dict[str, list[dict]] = {} result: dict[str, list[dict]] = {}
for key in self.MASTER_SPECS: for key in self.MASTER_SPECS: