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
@@ -218,6 +218,42 @@
</section> </section>
{% endif %} {% endif %}
{% if accounting and accounting.exists %}
<section class="rounded-2xl bg-white p-5 shadow-soft" id="full-accounting-export">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="font-semibold text-slate-900">Full Accounting Export · ODBC → SQLite</h2>
<p class="mt-1 text-sm text-slate-500">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.</p>
</div>
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">Read-only export</span>
</div>
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900"><strong>Before export:</strong> 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.</div>
<form method="post" action="/tools/tally/full-export" class="mt-5 grid gap-4 lg:grid-cols-4 lg:items-end">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
<label class="text-sm font-medium text-slate-700">Mapped Tally Company
<select name="tally_guid" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">Select mapped company</option>
{% for mapping in accounting.mappings or [] %}
{% if mapping.tally_guid and mapping.is_active %}
<option value="{{ mapping.tally_guid }}">{{ mapping.company_name }}{% if mapping.gstin %} · {{ mapping.gstin }}{% endif %}</option>
{% endif %}
{% endfor %}
</select>
</label>
<label class="text-sm font-medium text-slate-700">From Date<input type="date" name="date_from" required value="{{ transaction_date_from }}" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" /></label>
<label class="text-sm font-medium text-slate-700">To Date<input type="date" name="date_to" required value="{{ transaction_date_to }}" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" /></label>
<button id="fullExportButton" type="submit" class="rounded-xl bg-emerald-700 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-800">Export All to SQLite</button>
</form>
<div id="fullExportProgress" class="mt-5 hidden rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center justify-between gap-3"><div><div id="fullExportStage" class="font-semibold text-slate-900">Waiting…</div><div id="fullExportMessage" class="mt-1 text-xs text-slate-500"></div></div><div id="fullExportPct" class="text-lg font-bold text-slate-900">0%</div></div>
<div class="mt-3 h-3 overflow-hidden rounded-full bg-slate-200"><div id="fullExportBar" class="h-full bg-emerald-600 transition-all duration-300" style="width:0%"></div></div>
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"><div class="rounded-lg bg-white p-3"><div class="text-xs uppercase text-slate-500">Ledgers</div><div id="fullExportLedgers" class="mt-1 text-lg font-semibold">0</div></div><div class="rounded-lg bg-white p-3"><div class="text-xs uppercase text-slate-500">Vouchers</div><div id="fullExportVouchers" class="mt-1 text-lg font-semibold">0</div></div><div class="rounded-lg bg-white p-3"><div class="text-xs uppercase text-slate-500">Voucher Lines</div><div id="fullExportLines" class="mt-1 text-lg font-semibold">0</div></div><div class="rounded-lg bg-white p-3"><div class="text-xs uppercase text-slate-500">Stock Movements</div><div id="fullExportInventory" class="mt-1 text-lg font-semibold">0</div></div></div>
<div id="fullExportPaths" class="mt-3 break-all text-xs text-slate-500"></div><div id="fullExportError" class="mt-3 hidden rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700"></div>
</div>
</section>
{% endif %}
{% if accounting and accounting.exists and tally and tally.connected %} {% if accounting and accounting.exists and tally and tally.connected %}
<section class="rounded-2xl bg-white p-5 shadow-soft"> <section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
@@ -418,4 +454,29 @@
Phase 4 synchronizes accounting transactions read-only. Tally write-back remains disabled and is reserved for Phase 6. Phase 4 synchronizes accounting transactions read-only. Tally write-back remains disabled and is reserved for Phase 6.
</div> </div>
</div> </div>
<script>
(function(){
const clientId = {{ selected_client.id if selected_client else 'null' }};
if (!clientId) return;
const box=document.getElementById('fullExportProgress'); if(!box) return;
let completedReloaded=false;
function setText(id,v){const e=document.getElementById(id);if(e)e.textContent=String(v??'');}
async function pollFullExport(){
try{
const r=await fetch('/tools/tally/full-export/status?client_id='+encodeURIComponent(clientId),{cache:'no-store'});
const d=await r.json(); if(!r.ok||!d.ok||!d.job)return;
const j=d.job||{}, p=Math.max(0,Math.min(100,Number(j.percent)||0)); box.classList.remove('hidden');
setText('fullExportStage',j.stage||j.status||'Working…'); setText('fullExportMessage',j.message||''); setText('fullExportPct',Math.round(p)+'%');
document.getElementById('fullExportBar').style.width=p+'%';
const rows=j.rows||((j.mirror||{}).counts)||{}; setText('fullExportLedgers',rows.ledger_master||0); setText('fullExportVouchers',rows.voucher||rows.voucher_header||0); setText('fullExportLines',rows.voucher_ledger_entry||rows.voucher_ledger||0); setText('fullExportInventory',rows.inventory_movement||rows.inventory||0);
const paths=[]; if(j.mirror_db_path)paths.push('Mirror: '+j.mirror_db_path); if(j.accounting_db_path)paths.push('Client DB: '+j.accounting_db_path); setText('fullExportPaths',paths.join(' • '));
const er=document.getElementById('fullExportError'); if(j.error){er.textContent=j.error;er.classList.remove('hidden')}else{er.classList.add('hidden')}
const btn=document.getElementById('fullExportButton'); if(btn)btn.disabled=['queued','running'].includes(String(j.status||'').toLowerCase());
if(String(j.status||'').toLowerCase()==='completed'&&!completedReloaded){completedReloaded=true;setTimeout(()=>location.href='/tools/tally?client_id='+clientId+'&refresh=1',1800)}
}catch(e){}
}
pollFullExport(); setInterval(pollFullExport,1500);
})();
</script>
{% endblock %} {% endblock %}
+71 -1
View File
@@ -6,7 +6,7 @@ import re
from urllib.parse import quote from urllib.parse import quote
from fastapi import APIRouter, Form, Request from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse, JSONResponse
from sqlalchemy import select from sqlalchemy import select
from app.core.db.common import CommonSessionLocal from app.core.db.common import CommonSessionLocal
@@ -197,6 +197,7 @@ def tally_tool(
unmapped: int = 0, unmapped: int = 0,
synced: int = 0, synced: int = 0,
transactions_synced: int = 0, transactions_synced: int = 0,
export_started: int = 0,
error: str = "", error: str = "",
): ):
db = CommonSessionLocal() db = CommonSessionLocal()
@@ -547,6 +548,75 @@ def sync_tally_transactions(
finally: finally:
db.close() 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") @router.get("/depreciation")
def depreciation_it_tool( def depreciation_it_tool(
request: Request, request: Request,
+2 -2
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path from pathlib import Path
import zipfile import zipfile
ERP_LOCAL_AGENT_VERSION = "1.24.2" ERP_LOCAL_AGENT_VERSION = "1.24.3"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent" ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
@@ -65,7 +65,7 @@ def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme:
text = ( text = (
f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n" f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n"
"Existing storage, WebSocket tunnel, dashboard, Tally mapping and client .act functionality are preserved.\n" "Existing storage, WebSocket tunnel, dashboard, Tally mapping and client .act functionality are preserved.\n"
"Existing 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" "Dashboard: http://127.0.0.1:8788\nDesktop dashboard is single-instance: repeated shortcut clicks focus the existing window without starting another Local Agent.\nUpdate dashboard shows check, download, install, restart and completion progress.\n"
) )
_write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", text.encode("utf-8")) _write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", text.encode("utf-8"))
@@ -1,2 +1,2 @@
__version__ = "1.24.2" __version__ = "1.24.3"
AGENT_NAME = "ERP Local Agent" AGENT_NAME = "ERP Local Agent"
@@ -12,7 +12,10 @@ import shutil
import sqlite3 import sqlite3
import subprocess import subprocess
import sys import sys
from typing import Any import queue
import threading
import time
from typing import Any, Callable
MIRROR_SCHEMA_VERSION = "3.0.1" MIRROR_SCHEMA_VERSION = "3.0.1"
DEFAULT_DSN = "TallyODBC64_9000" DEFAULT_DSN = "TallyODBC64_9000"
@@ -136,10 +139,16 @@ class AccountingMirrorEngine:
).fetchone()[0]) ).fetchone()[0])
except sqlite3.Error: except sqlite3.Error:
unbalanced = 0 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 { return {
"ready": True, "path": str(db_path), "size_bytes": db_path.stat().st_size, "ready": True, "path": str(db_path), "size_bytes": db_path.stat().st_size,
"company": dict(company) if company else {}, "company": dict(company) if company else {},
"sync": dict(sync) if sync else {}, "counts": counts, "sync": dict(sync) if sync else {}, "counts": counts,
"voucher_period": voucher_period,
"unbalanced_vouchers": unbalanced, "unbalanced_vouchers": unbalanced,
} }
finally: finally:
@@ -222,21 +231,105 @@ class AccountingMirrorEngine:
} }
def sync(self, *, client_id: int, expected_company_name: str = "", expected_company_guid: str = "", 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) 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) self._ensure_extractor(paths)
shutil.rmtree(paths.work_root, ignore_errors=True) shutil.rmtree(paths.work_root, ignore_errors=True)
paths.work_root.mkdir(parents=True, exist_ok=True) paths.work_root.mkdir(parents=True, exist_ok=True)
output_dir = paths.work_root / "mirror_output_v3_0_1" output_dir = paths.work_root / "mirror_output_v3_0_1"
cp = subprocess.run( cmd = [str(paths.extractor_exe), "--dsn", dsn or DEFAULT_DSN, "--out", str(output_dir)]
[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.")
capture_output=True, text=True, timeout=max(60, int(timeout_seconds))
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") (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:]) 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" loader_copy = paths.work_root / "build_act_v3_0_1.py"
shutil.copy2(paths.loader_file, loader_copy) shutil.copy2(paths.loader_file, loader_copy)
lp = subprocess.run( lp = subprocess.run(
@@ -249,10 +342,10 @@ class AccountingMirrorEngine:
if lp.returncode != 0: if lp.returncode != 0:
raise RuntimeError("Accounting Mirror SQLite build failed: " + llog[-4000:]) raise RuntimeError("Accounting Mirror SQLite build failed: " + llog[-4000:])
# Proven v3.0.1 loader output.
candidate = output_dir / "accounting_mirror_v3_0_1.act" candidate = output_dir / "accounting_mirror_v3_0_1.act"
if not candidate.is_file(): if not candidate.is_file():
raise RuntimeError("Accounting Mirror database was not produced.") 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) self._add_auxiliary_masters(candidate, output_dir)
summary = self._summary(candidate) summary = self._summary(candidate)
@@ -266,6 +359,7 @@ class AccountingMirrorEngine:
if exp_guid and actual_guid and exp_guid != actual_guid: 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}.") 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) paths.current_mirror.parent.mkdir(parents=True, exist_ok=True)
temp = paths.current_mirror.with_suffix(".act.new") temp = paths.current_mirror.with_suffix(".act.new")
if temp.exists(): if temp.exists():
@@ -280,12 +374,14 @@ class AccountingMirrorEngine:
cache = self._cache_file(paths, actual_name, actual_guid) cache = self._cache_file(paths, actual_name, actual_guid)
shutil.copy2(paths.current_mirror, cache) shutil.copy2(paths.current_mirror, cache)
return { result = {
"ok": True, "synced_at_utc": _utc_now(), "company_name": actual_name, "ok": True, "synced_at_utc": _utc_now(), "company_name": actual_name,
"company_guid": actual_guid, "company_cache_path": str(cache), "company_guid": actual_guid, "company_cache_path": str(cache),
"extractor_log_tail": log[-1500:], "loader_log_tail": llog[-1500:], "extractor_log_tail": log[-1500:], "loader_log_tail": llog[-1500:],
**self.status(client_id), **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: def company_db(self, client_id: int, company_name: str, company_guid: str = "") -> Path:
paths = self.paths(client_id) 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: dict[str, threading.Thread] = {}
_CASH_LEDGER_THREADS_LOCK = threading.Lock() _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: def _cash_progress_snapshot(payload: dict[str, Any]) -> None:
"""Publish one lightweight cash-job snapshot for the local dashboard. """Publish one lightweight cash-job snapshot for the local dashboard.
@@ -148,6 +170,10 @@ class AgentCommandProcessor:
result = self._accounting_mirror_status(payload) result = self._accounting_mirror_status(payload)
elif action == "accounting_mirror_sync": elif action == "accounting_mirror_sync":
result = self._accounting_mirror_sync(payload) 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": elif action == "accounting_mirror_query":
result = self._accounting_mirror_query(payload) result = self._accounting_mirror_query(payload)
else: 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_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "native_voucher_engine_capability": True, "stock_item_native_voucher_capability": True, "stock_master_intelligence_capability": True, "opening_balance_correction_capability": True, "bank_reconciliation_read_capability": True,
"tally_transaction_sync_capability": True, "tally_transaction_sync_capability": True,
"dotnet_odbc_mirror_capability": True, "dotnet_odbc_mirror_capability": True,
"full_accounting_export_capability": True,
"accounting_mirror_progress_capability": True,
"mirror_first_accounting_reads": True, "mirror_first_accounting_reads": True,
"historical_learning_read_capability": True, "historical_learning_read_capability": True,
"purchase_posting_preflight_capability": True, "purchase_posting_preflight_capability": True,
@@ -2834,6 +2862,127 @@ class AgentCommandProcessor:
self.tally.bind_context(client_id, payload) self.tally.bind_context(client_id, payload)
return self.tally.refresh_mirror(company_name, company_guid, int(payload.get("timeout_seconds") or 300)) 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]: def _accounting_mirror_query(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id") or 0) client_id = int(payload.get("client_id") or 0)
if client_id <= 0: if client_id <= 0:
@@ -104,8 +104,8 @@ class AgentDashboard:
return self._json(dashboard.status()) return self._json(dashboard.status())
if self.path.startswith("/api/history"): if self.path.startswith("/api/history"):
return self._json(dashboard.history()) return self._json(dashboard.history())
if self.path.startswith("/api/cash-progress"): if self.path.startswith("/api/mirror-progress"):
return self._json(dashboard.cash_progress()) return self._json(dashboard.mirror_progress())
if self.path.split("?", 1)[0] == "/tally/ERP_Accounting_Mirror.tdl": if self.path.split("?", 1)[0] == "/tally/ERP_Accounting_Mirror.tdl":
info = dashboard._ensure_managed_tdl() info = dashboard._ensure_managed_tdl()
payload = Path(info["path"]).read_bytes() payload = Path(info["path"]).read_bytes()
@@ -188,8 +188,8 @@ class AgentDashboard:
"dashboard": {"host": self.config.dashboard_host, "port": self.config.dashboard_port}, "dashboard": {"host": self.config.dashboard_host, "port": self.config.dashboard_port},
} }
def cash_progress(self) -> dict[str, Any]: def mirror_progress(self) -> dict[str, Any]:
path = self.install_dir / "data" / "cash_payment_progress.json" path = self.install_dir / "data" / "accounting_mirror_progress.json"
if not path.exists(): if not path.exists():
return {"active": False, "job": None} return {"active": False, "job": None}
try: try:
@@ -197,7 +197,7 @@ class AgentDashboard:
except Exception: except Exception:
return {"active": False, "job": None} return {"active": False, "job": None}
status = str(job.get("status") or "").lower() 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]: def history(self) -> dict[str, Any]:
return { return {
@@ -213,7 +213,7 @@ class AgentDashboard:
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ERP Local Agent</title> <title>ERP Local Agent</title>
<style> <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> </style></head>
<body><div class="wrap"><h1>ERP Local Agent</h1><div class="muted">Local dashboard — available only on this computer</div> <body><div class="wrap"><h1>ERP Local Agent</h1><div class="muted">Local dashboard — available only on this computer</div>
<div class="grid"> <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 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</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"><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 class="section"><h2>Recent History</h2><div id="history" class="muted">Loading…</div></div>
</div> </div>
<script> <script>
@@ -235,8 +235,7 @@ let installWatching=false,installTarget='',offlineTicks=0,lastServerPercent=0;
function gb(n){return (n/1073741824).toFixed(1)+' GB'} 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 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 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 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 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 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; 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'); 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'; 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':'');} 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); 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)} 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){}} 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]))} 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)}} 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): def get_loaded_companies(self):
return self.live.get_loaded_companies() 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() client_id = self._client()
expected_name = str(company_name or self.context.get("company_name") or "").strip() 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() expected_guid = str(company_guid or self.context.get("tally_guid") or "").strip()
@@ -49,6 +49,7 @@ class MirrorFirstTallyConnector:
expected_company_guid=expected_guid, expected_company_guid=expected_guid,
dsn=str(self.context.get("dsn") or DEFAULT_DSN), dsn=str(self.context.get("dsn") or DEFAULT_DSN),
timeout_seconds=int(timeout_seconds or 300), timeout_seconds=int(timeout_seconds or 300),
progress_callback=progress_callback,
) )
def fetch_accounting_masters(self, company_name: str): def fetch_accounting_masters(self, company_name: str):