Add full Tally ODBC export with SQLite progress

This commit is contained in:
A R R R Associates
2026-09-06 18:39:47 +05:30
parent 6411cb14fa
commit 9500668413
8 changed files with 400 additions and 24 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path
import zipfile
ERP_LOCAL_AGENT_VERSION = "1.24.2"
ERP_LOCAL_AGENT_VERSION = "1.24.3"
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, accounting tools and controlled write-back are preserved.\nAccounting Mirror uses the proven v3.0.1 ODBC runtime for ledgers, vouchers, stock movement, HSN and GST rates.\nTally TDL is exposed through a stable local path and localhost URL so later agent updates can repair/replace the TDL without changing the Tally link.\n"
"Existing master/transaction sync, accounting tools and controlled write-back are preserved.\nAccounting Mirror uses the proven v3.0.1 ODBC runtime for ledgers, vouchers, stock movement, HSN and GST rates.\nTally TDL is exposed through a stable local path and localhost URL so later agent updates can repair/replace the TDL without changing the Tally link.\nFull Accounting Export runs asynchronously from the ERP, publishes live progress in both ERP and the Local Agent dashboard, and refreshes the client SQLite accounting datasets after the ODBC mirror is built.\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.24.2"
__version__ = "1.24.3"
AGENT_NAME = "ERP Local Agent"
@@ -12,7 +12,10 @@ import shutil
import sqlite3
import subprocess
import sys
from typing import Any
import queue
import threading
import time
from typing import Any, Callable
MIRROR_SCHEMA_VERSION = "3.0.1"
DEFAULT_DSN = "TallyODBC64_9000"
@@ -136,10 +139,16 @@ class AccountingMirrorEngine:
).fetchone()[0])
except sqlite3.Error:
unbalanced = 0
try:
period_row = db.execute("SELECT MIN(date) AS from_date, MAX(date) AS to_date FROM voucher").fetchone()
voucher_period = dict(period_row) if period_row else {"from_date": "", "to_date": ""}
except sqlite3.Error:
voucher_period = {"from_date": "", "to_date": ""}
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,
"voucher_period": voucher_period,
"unbalanced_vouchers": unbalanced,
}
finally:
@@ -222,21 +231,105 @@ class AccountingMirrorEngine:
}
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]:
dsn: str = DEFAULT_DSN, timeout_seconds: int = 300,
progress_callback: Callable[[dict[str, Any]], None] | None = None) -> dict[str, Any]:
paths = self.paths(client_id)
def report(percent: int, stage: str, message: str = "", **extra: Any) -> None:
if progress_callback is None:
return
payload = {
"percent": max(0, min(100, int(percent))),
"stage": str(stage or ""),
"message": str(message or stage or ""),
"updated_at_utc": _utc_now(),
}
payload.update(extra)
try:
progress_callback(payload)
except Exception:
self.logger.debug("Accounting Mirror progress callback failed", exc_info=True)
report(2, "Preparing ODBC mirror", "Preparing the proven Accounting Mirror runtime.")
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"
cp = subprocess.run(
[str(paths.extractor_exe), "--dsn", dsn or DEFAULT_DSN, "--out", str(output_dir)],
capture_output=True, text=True, timeout=max(60, int(timeout_seconds))
cmd = [str(paths.extractor_exe), "--dsn", dsn or DEFAULT_DSN, "--out", str(output_dir)]
report(5, "Connecting to Tally ODBC", "Connecting to Tally through the local ODBC DSN.")
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
bufsize=1, universal_newlines=True,
)
log = ((cp.stdout or "") + "\n" + (cp.stderr or "")).strip()
line_queue: queue.Queue[str | None] = queue.Queue()
lines: list[str] = []
def reader() -> None:
try:
assert proc.stdout is not None
for raw in proc.stdout:
line_queue.put(raw.rstrip("\r\n"))
finally:
line_queue.put(None)
threading.Thread(target=reader, name="accounting-mirror-output", daemon=True).start()
deadline = time.monotonic() + max(60, int(timeout_seconds))
stage_map = {
"CONNECTED": (8, "Connected to Tally ODBC"),
"COMPANY - BEFORE QUERY": (10, "Reading company"),
"COMPANY - ROWS READ:": (14, "Company read"),
"LEDGER MASTER - BEFORE QUERY": (15, "Reading ledgers"),
"LEDGER MASTER - ROWS READ:": (25, "Ledgers read"),
"STOCK MASTER - BEFORE QUERY": (26, "Reading stock items"),
"STOCK MASTER - ROWS READ:": (34, "Stock items read"),
"STOCK HSN MASTER - BEFORE QUERY": (35, "Reading HSN details"),
"STOCK HSN MASTER - ROWS READ:": (43, "HSN details read"),
"STOCK GST MASTER - BEFORE QUERY": (44, "Reading GST rates"),
"STOCK GST MASTER - ROWS READ:": (52, "GST rates read"),
"VOUCHER HEADER - BEFORE QUERY": (53, "Reading voucher headers"),
"VOUCHER HEADER - ROWS READ:": (65, "Voucher headers read"),
"VOUCHER LEDGER - BEFORE QUERY": (66, "Reading voucher ledger entries"),
"VOUCHER LEDGER - ROWS READ:": (78, "Voucher ledger entries read"),
"INVENTORY - BEFORE QUERY": (79, "Reading stock movements"),
"INVENTORY - ROWS READ:": (88, "Stock movements read"),
"SUCCESS": (89, "ODBC extraction complete"),
}
rows: dict[str, int] = {}
finished_reader = False
while not finished_reader:
if time.monotonic() > deadline:
proc.kill()
raise RuntimeError(f"Accounting Mirror ODBC extraction exceeded {max(60, int(timeout_seconds))} seconds.")
try:
line = line_queue.get(timeout=0.25)
except queue.Empty:
if proc.poll() is not None:
continue
continue
if line is None:
finished_reader = True
continue
lines.append(line)
upper = line.strip().upper()
for marker, (pct, stage) in stage_map.items():
if upper.startswith(marker):
if "ROWS READ:" in upper:
try:
count = int(re.search(r"ROWS READ:\s*(\d+)", upper).group(1))
except Exception:
count = 0
key = marker.split(" - ", 1)[0].lower().replace(" ", "_")
rows[key] = count
report(pct, stage, line.strip(), rows=dict(rows))
break
return_code = proc.wait(timeout=5)
log = "\n".join(lines).strip()
(paths.work_root / "extractor.log").write_text(log, encoding="utf-8")
if cp.returncode != 0:
if return_code != 0:
raise RuntimeError("Accounting Mirror ODBC extraction failed: " + log[-4000:])
report(90, "Building SQLite database", "Converting extracted Tally data into the client Accounting Mirror .act database.", rows=dict(rows))
loader_copy = paths.work_root / "build_act_v3_0_1.py"
shutil.copy2(paths.loader_file, loader_copy)
lp = subprocess.run(
@@ -249,10 +342,10 @@ class AccountingMirrorEngine:
if lp.returncode != 0:
raise RuntimeError("Accounting Mirror SQLite build failed: " + llog[-4000:])
# Proven v3.0.1 loader output.
candidate = output_dir / "accounting_mirror_v3_0_1.act"
if not candidate.is_file():
raise RuntimeError("Accounting Mirror database was not produced.")
report(93, "Finalising master tables", "Preparing auxiliary master tables and validating the SQLite mirror.", rows=dict(rows))
self._add_auxiliary_masters(candidate, output_dir)
summary = self._summary(candidate)
@@ -266,6 +359,7 @@ class AccountingMirrorEngine:
if exp_guid and actual_guid and exp_guid != actual_guid:
raise RuntimeError(f"Tally company GUID mismatch. ERP expected {exp_guid}, ODBC returned {actual_guid}.")
report(96, "Publishing Accounting Mirror", "Publishing the verified SQLite mirror inside the client's Accounting folder.", rows=dict(rows))
paths.current_mirror.parent.mkdir(parents=True, exist_ok=True)
temp = paths.current_mirror.with_suffix(".act.new")
if temp.exists():
@@ -280,12 +374,14 @@ class AccountingMirrorEngine:
cache = self._cache_file(paths, actual_name, actual_guid)
shutil.copy2(paths.current_mirror, cache)
return {
result = {
"ok": True, "synced_at_utc": _utc_now(), "company_name": actual_name,
"company_guid": actual_guid, "company_cache_path": str(cache),
"extractor_log_tail": log[-1500:], "loader_log_tail": llog[-1500:],
**self.status(client_id),
}
report(98, "Mirror ready", "Accounting Mirror database is ready for ERP synchronization.", rows=dict(rows), mirror=result.get("mirror") or {})
return result
def company_db(self, client_id: int, company_name: str, company_guid: str = "") -> Path:
paths = self.paths(client_id)
@@ -25,6 +25,28 @@ _CASH_PROGRESS_FILE = Path(__file__).resolve().parents[1] / "data" / "cash_payme
_CASH_LEDGER_THREADS: dict[str, threading.Thread] = {}
_CASH_LEDGER_THREADS_LOCK = threading.Lock()
_MIRROR_EXPORT_THREADS: dict[str, threading.Thread] = {}
_MIRROR_EXPORT_THREADS_LOCK = threading.Lock()
_MIRROR_EXPORT_LOCK = threading.Lock()
_MIRROR_EXPORT_PROGRESS_FILE = Path(__file__).resolve().parents[1] / "data" / "accounting_mirror_progress.json"
def _mirror_export_progress(payload: dict[str, Any]) -> None:
try:
_MIRROR_EXPORT_PROGRESS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = _MIRROR_EXPORT_PROGRESS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, default=str), encoding="utf-8")
tmp.replace(_MIRROR_EXPORT_PROGRESS_FILE)
except Exception:
pass
def _mirror_export_progress_read() -> dict[str, Any]:
try:
if not _MIRROR_EXPORT_PROGRESS_FILE.is_file():
return {}
return json.loads(_MIRROR_EXPORT_PROGRESS_FILE.read_text(encoding="utf-8")) or {}
except Exception:
return {}
def _cash_progress_snapshot(payload: dict[str, Any]) -> None:
"""Publish one lightweight cash-job snapshot for the local dashboard.
@@ -148,6 +170,10 @@ class AgentCommandProcessor:
result = self._accounting_mirror_status(payload)
elif action == "accounting_mirror_sync":
result = self._accounting_mirror_sync(payload)
elif action == "accounting_full_export_start":
result = self._accounting_full_export_start(payload)
elif action == "accounting_full_export_status":
result = self._accounting_full_export_status(payload)
elif action == "accounting_mirror_query":
result = self._accounting_mirror_query(payload)
else:
@@ -174,6 +200,8 @@ class AgentCommandProcessor:
"tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "native_voucher_engine_capability": True, "stock_item_native_voucher_capability": True, "stock_master_intelligence_capability": True, "opening_balance_correction_capability": True, "bank_reconciliation_read_capability": True,
"tally_transaction_sync_capability": True,
"dotnet_odbc_mirror_capability": True,
"full_accounting_export_capability": True,
"accounting_mirror_progress_capability": True,
"mirror_first_accounting_reads": True,
"historical_learning_read_capability": True,
"purchase_posting_preflight_capability": True,
@@ -2834,6 +2862,127 @@ class AgentCommandProcessor:
self.tally.bind_context(client_id, payload)
return self.tally.refresh_mirror(company_name, company_guid, int(payload.get("timeout_seconds") or 300))
def _accounting_full_export_start(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.")
if not self.store.exists(client_id):
raise ValueError("Accounting storage is not initialized for this client.")
tally_guid = str(payload.get("tally_guid") or "").strip()
if not tally_guid:
raise ValueError("Select the mapped Tally company to export.")
mapping = self.store.get_active_mapping_by_guid(client_id, tally_guid)
company_name = str(mapping.get("company_name") or "").strip()
if not company_name:
raise ValueError("The selected Tally mapping has no company name.")
date_from = str(payload.get("date_from") or "").strip()
date_to = str(payload.get("date_to") or "").strip()
try:
start = _dt_date.fromisoformat(date_from)
end = _dt_date.fromisoformat(date_to)
except Exception as exc:
raise ValueError("Select valid From Date and To Date for the full accounting export.") from exc
if start > end:
raise ValueError("From Date cannot be after To Date.")
requested_by = payload.get("requested_by_user_id")
current = _mirror_export_progress_read()
if str(current.get("status") or "").lower() in {"queued", "running"}:
return {"started": False, "already_running": True, "job": current, "agent": self._agent_info()}
job_id = str(uuid.uuid4())
now = datetime.now(timezone.utc).isoformat()
initial = {
"job_id": job_id, "status": "queued", "percent": 1,
"stage": "Queued", "message": "Full Accounting Export queued on the Local Agent.",
"client_id": client_id, "company_name": company_name, "tally_guid": tally_guid,
"date_from": date_from, "date_to": date_to, "rows": {},
"started_at_utc": now, "updated_at_utc": now, "finished_at_utc": "",
"error": "", "mirror_db_path": "", "accounting_db_path": str(self.store.db_path(client_id)),
}
_mirror_export_progress(initial)
worker_payload = dict(payload)
worker_payload.update({"job_id": job_id, "company_name": company_name})
thread = threading.Thread(
target=self._run_accounting_full_export, args=(job_id, worker_payload, mapping),
name=f"accounting-full-export-{client_id}", daemon=True,
)
with _MIRROR_EXPORT_THREADS_LOCK:
_MIRROR_EXPORT_THREADS[job_id] = thread
thread.start()
return {"started": True, "job_id": job_id, "job": initial, "agent": self._agent_info()}
def _run_accounting_full_export(self, job_id: str, payload: dict[str, Any], mapping: dict[str, Any]) -> None:
client_id = int(payload.get("client_id") or 0)
company_name = str(payload.get("company_name") or mapping.get("company_name") or "").strip()
tally_guid = str(payload.get("tally_guid") or "").strip()
date_from = str(payload.get("date_from") or "").strip()
date_to = str(payload.get("date_to") or "").strip()
requested_by = payload.get("requested_by_user_id")
base = _mirror_export_progress_read()
def publish(**updates: Any) -> None:
nonlocal base
current = _mirror_export_progress_read()
if str(current.get("job_id") or "") != job_id:
current = dict(base)
current.update(updates)
current["job_id"] = job_id
current["updated_at_utc"] = datetime.now(timezone.utc).isoformat()
base = current
_mirror_export_progress(current)
acquired = _MIRROR_EXPORT_LOCK.acquire(blocking=False)
if not acquired:
publish(status="failed", percent=100, stage="Busy", error="Another Accounting Mirror export is already running on this workstation.", finished_at_utc=datetime.now(timezone.utc).isoformat())
return
try:
publish(status="running", percent=2, stage="Starting", message="Starting read-only ODBC export from TallyPrime.")
def mirror_progress(progress: dict[str, Any]) -> None:
updates = dict(progress or {})
updates["status"] = "running"
publish(**updates)
mirror_result = self.tally.mirror.sync(
client_id=client_id, expected_company_name=company_name, expected_company_guid=tally_guid,
dsn=str(payload.get("dsn") or "TallyODBC64_9000"),
timeout_seconds=max(300, int(payload.get("timeout_seconds") or 1800)),
progress_callback=mirror_progress,
)
publish(status="running", percent=98, stage="Updating client accounting database", message="Refreshing the existing client .act transaction snapshot from the completed ODBC mirror. Master analysis remains available directly from the mirror without clearing any existing auxiliary masters.", mirror_db_path=str(mirror_result.get("mirror_db_path") or ""))
transactions = self.tally.mirror.transactions(client_id, company_name, date_from, date_to, tally_guid)
transaction_sync = self.store.replace_transaction_snapshot(
client_id, mapping={**mapping, "company_name": company_name}, transactions=transactions,
requested_by_user_id=int(requested_by) if requested_by not in (None, "") else None,
)
mirror_summary = (mirror_result.get("mirror") or {})
counts = mirror_summary.get("counts") or {}
publish(
status="completed", percent=100, stage="Complete",
message="Full Accounting Export completed. ERP analysis tools can now read the refreshed SQLite data without re-extracting from Tally.",
finished_at_utc=datetime.now(timezone.utc).isoformat(), error="",
rows=counts, mirror=mirror_summary, mirror_db_path=str(mirror_result.get("mirror_db_path") or ""),
accounting_db_path=str(self.store.db_path(client_id)),
transaction_sync=transaction_sync,
)
self.logger.info("Full Accounting Export completed client_id=%s company=%s vouchers=%s", client_id, company_name, counts.get("voucher", 0))
except Exception as exc:
self.logger.exception("Full Accounting Export failed client_id=%s company=%s: %s", client_id, company_name, exc)
publish(status="failed", percent=100, stage="Export failed", message=str(exc), error=str(exc), finished_at_utc=datetime.now(timezone.utc).isoformat())
finally:
_MIRROR_EXPORT_LOCK.release()
with _MIRROR_EXPORT_THREADS_LOCK:
_MIRROR_EXPORT_THREADS.pop(job_id, None)
def _accounting_full_export_status(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id") or 0)
job = _mirror_export_progress_read()
if not job or (client_id > 0 and int(job.get("client_id") or 0) != client_id):
return {"active": False, "job": None, "agent": self._agent_info()}
status = str(job.get("status") or "").lower()
return {"active": status in {"queued", "running"}, "job": job, "agent": self._agent_info()}
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:
@@ -104,8 +104,8 @@ class AgentDashboard:
return self._json(dashboard.status())
if self.path.startswith("/api/history"):
return self._json(dashboard.history())
if self.path.startswith("/api/cash-progress"):
return self._json(dashboard.cash_progress())
if self.path.startswith("/api/mirror-progress"):
return self._json(dashboard.mirror_progress())
if self.path.split("?", 1)[0] == "/tally/ERP_Accounting_Mirror.tdl":
info = dashboard._ensure_managed_tdl()
payload = Path(info["path"]).read_bytes()
@@ -188,8 +188,8 @@ class AgentDashboard:
"dashboard": {"host": self.config.dashboard_host, "port": self.config.dashboard_port},
}
def cash_progress(self) -> dict[str, Any]:
path = self.install_dir / "data" / "cash_payment_progress.json"
def mirror_progress(self) -> dict[str, Any]:
path = self.install_dir / "data" / "accounting_mirror_progress.json"
if not path.exists():
return {"active": False, "job": None}
try:
@@ -197,7 +197,7 @@ class AgentDashboard:
except Exception:
return {"active": False, "job": None}
status = str(job.get("status") or "").lower()
return {"active": status not in {"completed", "cancelled", "failed", "paused", ""}, "job": job}
return {"active": status in {"queued", "running"}, "job": job}
def history(self) -> dict[str, Any]:
return {
@@ -213,7 +213,7 @@ class AgentDashboard:
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ERP Local Agent</title>
<style>
body{font-family:Segoe UI,Arial,sans-serif;background:#f4f6f8;color:#17202a;margin:0}.wrap{max-width:1100px;margin:28px auto;padding:0 18px}h1{margin:0 0 4px}.muted{color:#6b7280}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px;margin:22px 0}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:18px;box-shadow:0 1px 2px rgba(0,0,0,.04)}.label{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:#6b7280}.value{font-size:22px;font-weight:650;margin-top:8px}.ok{color:#087f5b}.bad{color:#c92a2a}.warn{color:#b26a00}button{border:0;border-radius:8px;padding:10px 14px;margin:4px 6px 4px 0;font-weight:600;cursor:pointer;background:#111827;color:#fff}button.secondary{background:#e5e7eb;color:#111827}.copyrow{display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center;margin-top:8px}.copybox{width:100%;box-sizing:border-box;border:1px solid #d1d5db;border-radius:8px;padding:10px;background:#f8fafc;font-family:Consolas,monospace;font-size:12px;color:#111827}button:disabled{opacity:.5;cursor:not-allowed}.section{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:18px;margin:14px 0}table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:8px;border-bottom:1px solid #eee;vertical-align:top}#message{margin-top:10px;padding:10px;border-radius:8px;display:none}.msgok{display:block!important;background:#e6fcf5;color:#087f5b}.msgbad{display:block!important;background:#fff5f5;color:#c92a2a}code{background:#f3f4f6;padding:2px 5px;border-radius:4px}.progressbox{margin-top:14px;padding:14px;border:1px solid #dbe3ea;border-radius:10px;background:#f8fafc}.progresshead{display:flex;justify-content:space-between;gap:12px;align-items:center}.progressbar{height:12px;background:#e5e7eb;border-radius:999px;overflow:hidden;margin-top:10px}.progressfill{height:100%;width:0%;background:#111827;transition:width .45s ease}.progressmsg{margin-top:8px;font-size:13px;color:#4b5563}.pulse{animation:pulse 1.2s ease-in-out infinite}.cashsteps{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:8px;margin-top:12px}.cashstep{padding:9px 10px;border-radius:8px;background:#f3f4f6;font-size:12px}.cashstep.done{background:#e6fcf5;color:#087f5b}.cashstep.run{background:#e7f5ff;color:#1864ab;font-weight:700}.metricgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-top:12px}.metric{background:#f8fafc;border:1px solid #e5e7eb;border-radius:8px;padding:10px}.metric b{display:block;font-size:18px;margin-top:3px}@keyframes pulse{0%,100%{opacity:.65}50%{opacity:1}}
body{font-family:Segoe UI,Arial,sans-serif;background:#f4f6f8;color:#17202a;margin:0}.wrap{max-width:1100px;margin:28px auto;padding:0 18px}h1{margin:0 0 4px}.muted{color:#6b7280}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px;margin:22px 0}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:18px;box-shadow:0 1px 2px rgba(0,0,0,.04)}.label{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:#6b7280}.value{font-size:22px;font-weight:650;margin-top:8px}.ok{color:#087f5b}.bad{color:#c92a2a}.warn{color:#b26a00}button{border:0;border-radius:8px;padding:10px 14px;margin:4px 6px 4px 0;font-weight:600;cursor:pointer;background:#111827;color:#fff}button.secondary{background:#e5e7eb;color:#111827}.copyrow{display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center;margin-top:8px}.copybox{width:100%;box-sizing:border-box;border:1px solid #d1d5db;border-radius:8px;padding:10px;background:#f8fafc;font-family:Consolas,monospace;font-size:12px;color:#111827}button:disabled{opacity:.5;cursor:not-allowed}.section{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:18px;margin:14px 0}table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:8px;border-bottom:1px solid #eee;vertical-align:top}#message{margin-top:10px;padding:10px;border-radius:8px;display:none}.msgok{display:block!important;background:#e6fcf5;color:#087f5b}.msgbad{display:block!important;background:#fff5f5;color:#c92a2a}code{background:#f3f4f6;padding:2px 5px;border-radius:4px}.progressbox{margin-top:14px;padding:14px;border:1px solid #dbe3ea;border-radius:10px;background:#f8fafc}.progresshead{display:flex;justify-content:space-between;gap:12px;align-items:center}.progressbar{height:12px;background:#e5e7eb;border-radius:999px;overflow:hidden;margin-top:10px}.progressfill{height:100%;width:0%;background:#111827;transition:width .45s ease}.progressmsg{margin-top:8px;font-size:13px;color:#4b5563}.pulse{animation:pulse 1.2s ease-in-out infinite}.mirrorsteps{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:8px;margin-top:12px}.mirrorstep{padding:9px 10px;border-radius:8px;background:#f3f4f6;font-size:12px}.mirrorstep.done{background:#e6fcf5;color:#087f5b}.mirrorstep.run{background:#e7f5ff;color:#1864ab;font-weight:700}.metricgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-top:12px}.metric{background:#f8fafc;border:1px solid #e5e7eb;border-radius:8px;padding:10px}.metric b{display:block;font-size:18px;margin-top:3px}@keyframes pulse{0%,100%{opacity:.65}50%{opacity:1}}
</style></head>
<body><div class="wrap"><h1>ERP Local Agent</h1><div class="muted">Local dashboard — available only on this computer</div>
<div class="grid">
@@ -227,7 +227,7 @@ body{font-family:Segoe UI,Arial,sans-serif;background:#f4f6f8;color:#17202a;marg
<div id="progressBox" class="progressbox" style="display:none"><div class="progresshead"><strong id="progressTitle">Update</strong><span id="progressPct">0%</span></div><div class="progressbar"><div id="progressFill" class="progressfill"></div></div><div id="progressMsg" class="progressmsg">Waiting…</div></div></div>
<div class="section"><h2>Tally</h2><button onclick="action('/api/tally/check')">Test Tally Connection</button><p class="muted">Tally is checked locally at <code>127.0.0.1:9000</code>. Port 9000 is not exposed to the internet.</p></div>
<div class="section"><h2>Tally Accounting Mirror TDL</h2><p class="muted">Link Tally once to the stable local path below. Local Agent updates keep this same file path and refresh its contents, so future TDL repairs do not require a new Tally link.</p><div class="label">Stable Local TDL Path</div><div class="copyrow"><input id="tdlPath" class="copybox" readonly value="Loading…"><button onclick="copyText('tdlPath')">Copy Path</button></div><div class="label" style="margin-top:12px">Local TDL URL</div><div class="copyrow"><input id="tdlUrl" class="copybox" readonly value="Loading…"><button onclick="copyText('tdlUrl')">Copy URL</button></div><div id="tdlMeta" class="muted" style="margin-top:8px"></div><button class="secondary" onclick="repairTdl()">Repair / Refresh TDL</button><p class="muted">For Tally's Local TDL setting, use the stable file path if your TallyPrime build does not accept an HTTP URL. The URL is also kept stable for tools that support URL loading.</p></div>
<div class="section" id="cashJobSection" style="display:none"><div class="progresshead"><div><h2 style="margin:0">Cash Payment Compliance</h2><div id="cashJobSub" class="muted">Background Tally job</div></div><strong id="cashJobPct">0%</strong></div><div class="progressbar"><div id="cashJobFill" class="progressfill"></div></div><div id="cashJobStage" class="progressmsg">Waiting…</div><div class="metricgrid"><div class="metric"><span class="label">Days</span><b id="cashDays">0 / 0</b></div><div class="metric"><span class="label">Cash vouchers</span><b id="cashVouchers">0</b></div><div class="metric"><span class="label">Tally requests</span><b id="cashRequests">0</b></div><div class="metric"><span class="label">Current date</span><b id="cashDate">—</b></div></div><div class="cashsteps"><div id="cs1" class="cashstep">1. Connect to Tally</div><div id="cs2" class="cashstep">2. Confirm Cash ledger</div><div id="cs3" class="cashstep">3. Extract transactions</div><div id="cs4" class="cashstep">4. Analyse threshold</div><div id="cs5" class="cashstep">5. Complete</div></div><div id="cashError" class="msgbad" style="margin-top:10px"></div></div>
<div class="section" id="mirrorJobSection" style="display:none"><div class="progresshead"><div><h2 style="margin:0">Full Accounting Export</h2><div id="mirrorJobSub" class="muted">ODBC → SQLite .act</div></div><strong id="mirrorJobPct">0%</strong></div><div class="progressbar"><div id="mirrorJobFill" class="progressfill"></div></div><div id="mirrorJobStage" class="progressmsg">Waiting…</div><div class="metricgrid"><div class="metric"><span class="label">Ledgers</span><b id="mirrorLedgers">0</b></div><div class="metric"><span class="label">Vouchers</span><b id="mirrorVouchers">0</b></div><div class="metric"><span class="label">Voucher Lines</span><b id="mirrorLines">0</b></div><div class="metric"><span class="label">Stock Movements</span><b id="mirrorInventory">0</b></div></div><div id="mirrorPath" class="muted" style="margin-top:10px"></div><div id="mirrorError" class="msgbad" style="margin-top:10px"></div></div>
<div class="section"><h2>Recent History</h2><div id="history" class="muted">Loading…</div></div>
</div>
<script>
@@ -235,8 +235,7 @@ let installWatching=false,installTarget='',offlineTicks=0,lastServerPercent=0;
function gb(n){return (n/1073741824).toFixed(1)+' GB'}
function setProgress(title,pct,text,active=true){let b=document.getElementById('progressBox');b.style.display='block';document.getElementById('progressTitle').textContent=title;let p=Math.max(0,Math.min(100,Number(pct)||0));lastServerPercent=Math.max(lastServerPercent,p);document.getElementById('progressPct').textContent=Math.round(p)+'%';document.getElementById('progressFill').style.width=p+'%';document.getElementById('progressMsg').textContent=text||'';document.getElementById('progressFill').className='progressfill'+(active&&p<100?' pulse':'')}
function applyProgress(u){let p=(u&&u.progress)||{};if(!p.operation)return;let title=p.operation==='check'?'Checking for Updates':p.operation==='download'?'Downloading Update':'Applying Update';setProgress(title,p.percent||0,p.message||p.phase||'',!['updated','downloaded','current','available','error'].includes(p.status));if(p.operation==='install'&&p.target_version){installTarget=p.target_version}}
function setCashStep(id,state){let e=document.getElementById(id);e.className='cashstep'+(state?' '+state:'')}
async function refreshCashProgress(){try{const r=await fetch('/api/cash-progress',{cache:'no-store'});const d=await r.json();const j=d.job||{};if(!j.job_id){document.getElementById('cashJobSection').style.display='none';return}document.getElementById('cashJobSection').style.display='block';let p=Math.max(0,Math.min(100,Number(j.percent)||0));document.getElementById('cashJobPct').textContent=Math.round(p)+'%';document.getElementById('cashJobFill').style.width=p+'%';document.getElementById('cashJobStage').textContent=j.stage||j.status||'Working…';document.getElementById('cashJobSub').textContent=(j.company_name||'Tally company')+(j.cash_ledgers&&j.cash_ledgers.length?' • Cash ledger: '+j.cash_ledgers.join(', '):'');document.getElementById('cashDays').textContent=String(j.completed_days||0)+' / '+String(j.total_days||0);document.getElementById('cashVouchers').textContent=String(j.vouchers_cached||0);document.getElementById('cashRequests').textContent=String(j.tally_requests||0);document.getElementById('cashDate').textContent=j.current_date||'—';let st=String(j.stage||'').toLowerCase(),status=String(j.status||'').toLowerCase();setCashStep('cs1',(p>=10?'done':(p>0?'run':'')));setCashStep('cs2',(p>=20?'done':(st.includes('ledger')?'run':'')));setCashStep('cs3',(p>=93?'done':(p>=20?'run':'')));setCashStep('cs4',(status==='completed'?'done':(st.includes('analys')?'run':'')));setCashStep('cs5',(status==='completed'?'done':''));let er=document.getElementById('cashError');if(j.error){er.style.display='block';er.textContent=j.error}else{er.style.display='none'}}catch(e){}}
async function refreshMirrorProgress(){try{const r=await fetch('/api/mirror-progress',{cache:'no-store'});const d=await r.json();const j=d.job||{};if(!j.job_id){document.getElementById('mirrorJobSection').style.display='none';return}document.getElementById('mirrorJobSection').style.display='block';let p=Math.max(0,Math.min(100,Number(j.percent)||0));document.getElementById('mirrorJobPct').textContent=Math.round(p)+'%';document.getElementById('mirrorJobFill').style.width=p+'%';document.getElementById('mirrorJobFill').className='progressfill'+((j.status==='running'||j.status==='queued')&&p<100?' pulse':'');document.getElementById('mirrorJobStage').textContent=j.message||j.stage||j.status||'Working…';document.getElementById('mirrorJobSub').textContent=(j.company_name||'Tally company')+' • '+(j.date_from||'')+(j.date_to?' → '+j.date_to:'');let rows=j.rows||((j.mirror||{}).counts)||{};document.getElementById('mirrorLedgers').textContent=String(rows.ledger_master||0);document.getElementById('mirrorVouchers').textContent=String(rows.voucher||rows.voucher_header||0);document.getElementById('mirrorLines').textContent=String(rows.voucher_ledger_entry||rows.voucher_ledger||0);document.getElementById('mirrorInventory').textContent=String(rows.inventory_movement||rows.inventory||0);document.getElementById('mirrorPath').textContent=j.mirror_db_path?'SQLite mirror: '+j.mirror_db_path:'';let er=document.getElementById('mirrorError');if(j.error){er.style.display='block';er.textContent=j.error}else{er.style.display='none'}}catch(e){}}
async function refresh(){try{const r=await fetch('/api/status',{cache:'no-store'});if(!r.ok)throw new Error('status unavailable');const s=await r.json();offlineTicks=0;
let c=s.agent.connection_state||'unknown';let ce=document.getElementById('connection');ce.textContent=c.charAt(0).toUpperCase()+c.slice(1);ce.className='value '+(c==='connected'?'ok':'bad');
document.getElementById('heartbeat').textContent=s.agent.last_heartbeat_utc?'Last heartbeat: '+s.agent.last_heartbeat_utc:'No heartbeat recorded';
@@ -246,7 +245,7 @@ document.getElementById('storage').textContent=gb(s.storage.free_bytes);document
if(s.tdl){document.getElementById('tdlPath').value=s.tdl.path||'';document.getElementById('tdlUrl').value=s.tdl.url||'';document.getElementById('tdlMeta').textContent='Mirror '+(s.tdl.version||'')+' • SHA256 '+String(s.tdl.sha256||'').slice(0,16)+'…'+(s.tdl.repaired?' • refreshed now':'');}
document.getElementById('check').disabled=!!s.update.busy;document.getElementById('download').disabled=!s.update.update_available||!!s.update.busy;document.getElementById('install').disabled=!s.update.downloaded||!!s.update.busy;applyProgress(s.update);
if(installWatching&&installTarget&&s.agent.version===installTarget){setProgress('Update Complete',100,'ERP Local Agent '+installTarget+' is running. Updated dashboard loaded.',false);msg('Update completed successfully.',true);installWatching=false;setTimeout(()=>location.reload(),1200)}
await refreshHistory();await refreshCashProgress();}catch(e){if(installWatching){offlineTicks++;let estimated=Math.min(92,Math.max(lastServerPercent,35+offlineTicks*3));setProgress('Applying Update',estimated,'Local Agent is restarting. Waiting for the updated dashboard to come back online…',true)}else{console.log(e)}}}
await refreshHistory();await refreshMirrorProgress();}catch(e){if(installWatching){offlineTicks++;let estimated=Math.min(92,Math.max(lastServerPercent,35+offlineTicks*3));setProgress('Applying Update',estimated,'Local Agent is restarting. Waiting for the updated dashboard to come back online…',true)}else{console.log(e)}}}
async function refreshHistory(){try{const r=await fetch('/api/history',{cache:'no-store'});const h=await r.json();let rows=h.events.slice(0,12).map(x=>'<tr><td>'+x.occurred_at_utc+'</td><td>'+x.level+'</td><td>'+x.event_type+'</td><td>'+escapeHtml(x.message)+'</td></tr>').join('');document.getElementById('history').innerHTML='<table><thead><tr><th>Time UTC</th><th>Level</th><th>Event</th><th>Message</th></tr></thead><tbody>'+rows+'</tbody></table>'}catch(e){}}
function escapeHtml(v){return String(v||'').replace(/[&<>'"]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#039;','"':'&quot;'}[m]))}
async function checkUpdate(){setProgress('Checking for Updates',10,'Checking ERP for the latest Local Agent version…',true);msg('Checking for updates…',true);try{const r=await fetch('/api/update/check',{method:'POST'});const j=await r.json();if(!r.ok||!j.ok)throw new Error(j.error||'Update check failed');applyProgress(j.update);msg(j.update.update_available?'Update '+j.update.latest_version+' is available.':'Local Agent is up to date.',true);await refresh()}catch(e){setProgress('Update Check',100,e.message,false);msg(e.message,false)}}
@@ -39,7 +39,7 @@ class MirrorFirstTallyConnector:
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):
def refresh_mirror(self, company_name: str = "", company_guid: str = "", timeout_seconds: int = 300, progress_callback=None):
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()
@@ -49,6 +49,7 @@ class MirrorFirstTallyConnector:
expected_company_guid=expected_guid,
dsn=str(self.context.get("dsn") or DEFAULT_DSN),
timeout_seconds=int(timeout_seconds or 300),
progress_callback=progress_callback,
)
def fetch_accounting_masters(self, company_name: str):