Integrate read-only accounting mirror analytics runtime

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