diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html index fb88351..354e827 100644 --- a/app/modules/accounting/templates/accounting/tally.html +++ b/app/modules/accounting/templates/accounting/tally.html @@ -218,6 +218,42 @@ {% endif %} + {% if accounting and accounting.exists %} +
+
+
+

Full Accounting Export · ODBC → SQLite

+

Export the selected mapped Tally company into the client Accounting Mirror .act database in local storage. This refreshes ledgers, vouchers, voucher ledger entries, stock items, stock movements, HSN and GST rates, then refreshes the existing client .act snapshots used by older accounting tools.

+
+ Read-only export +
+
Before export: open the mapped company in TallyPrime and set Tally's active period (Alt+F2) to cover the full period you want exported. The ODBC mirror reads Tally's active period; the ERP does not change Tally's period automatically.
+
+ + + + + + +
+ +
+ {% endif %} + {% if accounting and accounting.exists and tally and tally.connected %}
@@ -418,4 +454,29 @@ Phase 4 synchronizes accounting transactions read-only. Tally write-back remains disabled and is reserved for Phase 6.
+ + {% endblock %} diff --git a/app/modules/accounting/ui.py b/app/modules/accounting/ui.py index 4a3dc90..a68958a 100644 --- a/app/modules/accounting/ui.py +++ b/app/modules/accounting/ui.py @@ -6,7 +6,7 @@ import re from urllib.parse import quote from fastapi import APIRouter, Form, Request -from fastapi.responses import RedirectResponse +from fastapi.responses import RedirectResponse, JSONResponse from sqlalchemy import select from app.core.db.common import CommonSessionLocal @@ -197,6 +197,7 @@ def tally_tool( unmapped: int = 0, synced: int = 0, transactions_synced: int = 0, + export_started: int = 0, error: str = "", ): db = CommonSessionLocal() @@ -547,6 +548,75 @@ def sync_tally_transactions( finally: db.close() +@router.post("/full-export") +def start_full_accounting_export( + request: Request, + client_id: int = Form(...), + tally_guid: str = Form(...), + date_from: str = Form(...), + date_to: str = Form(...), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_partner(request, db, "accounting.tally.sync_transactions") + if response: + return response + client, _clients, scope = _find_visible_client(db, request, user, client_id) + if not client: + return _denied() + try: + start = date.fromisoformat(str(date_from or "")) + end = date.fromisoformat(str(date_to or "")) + if start > end: + raise ValueError("From Date cannot be after To Date.") + except Exception as exc: + return RedirectResponse(url=f"/tools/tally?client_id={client.id}&error={quote(str(exc))}", status_code=303) + node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) + if not node or not _node_online(node): + return RedirectResponse(url=f"/tools/tally?client_id={client.id}&error={quote('ERP Local Agent is offline for the active branch.')}", status_code=303) + result = request_agent_command( + node.node_code, "accounting_full_export_start", + { + **_accounting_storage_payload(client, _financial_year_for_date(start)), + "tally_guid": str(tally_guid or "").strip(), + "date_from": start.isoformat(), "date_to": end.isoformat(), + "requested_by_user_id": int(user.id), "timeout_seconds": 1800, + }, timeout_seconds=20, + ) + if not result.get("ok"): + raise RuntimeError(str(result.get("error") or "Full Accounting Export could not be started.")) + return RedirectResponse(url=f"/tools/tally?client_id={client.id}&refresh=1&export_started=1", status_code=303) + except Exception as exc: + return RedirectResponse(url=f"/tools/tally?client_id={client_id}&error={quote(str(exc))}", status_code=303) + finally: + db.close() + + +@router.get("/full-export/status") +def full_accounting_export_status(request: Request, client_id: int): + db = CommonSessionLocal() + try: + user, response = _require_partner(request, db, "accounting.tally.view") + if response: + return JSONResponse({"ok": False, "error": "Access denied."}, status_code=403) + client, _clients, scope = _find_visible_client(db, request, user, client_id) + if not client: + return JSONResponse({"ok": False, "error": "Client is not available."}, status_code=404) + node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) + if not node or not _node_online(node): + return JSONResponse({"ok": False, "error": "ERP Local Agent is offline."}, status_code=503) + result = request_agent_command( + node.node_code, "accounting_full_export_status", _accounting_storage_payload(client), timeout_seconds=10 + ) + if not result.get("ok"): + return JSONResponse({"ok": False, "error": str(result.get("error") or "Progress lookup failed.")}, status_code=500) + return JSONResponse({"ok": True, **(result.get("result") or {})}) + finally: + db.close() + + @router.get("/depreciation") def depreciation_it_tool( request: Request, diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index ea565de..3366617 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -4,7 +4,7 @@ import io from pathlib import Path import zipfile -ERP_LOCAL_AGENT_VERSION = "1.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")) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index 9e1022d..e6f3df5 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.24.2" +__version__ = "1.24.3" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_mirror.py b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_mirror.py index 7e67b09..4488294 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_mirror.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_mirror.py @@ -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) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py index 137a78a..1887b18 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py @@ -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: diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/dashboard.py b/app/modules/documents/local_agent_runtime/erp_local_agent/dashboard.py index c2c558c..432e71d 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/dashboard.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/dashboard.py @@ -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: ERP Local Agent

ERP Local Agent

Local dashboard — available only on this computer
@@ -227,7 +227,7 @@ body{font-family:Segoe UI,Arial,sans-serif;background:#f4f6f8;color:#17202a;marg

Tally

Tally is checked locally at 127.0.0.1:9000. Port 9000 is not exposed to the internet.

Tally Accounting Mirror TDL

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.

Stable Local TDL Path
Local TDL URL

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.

- +

Recent History

Loading…