Add cash ledger extraction and dual progress UI

This commit is contained in:
A R R R Associates
2026-09-04 14:52:24 +05:30
parent c418dcb45a
commit 58752e30e1
6 changed files with 299 additions and 50 deletions
@@ -1,2 +1,2 @@
__version__ = "1.22.13"
__version__ = "1.22.14"
AGENT_NAME = "ERP Local Agent"
@@ -6,6 +6,7 @@ import time
import threading
import json
import uuid
from pathlib import Path
from typing import Any
from . import __version__
@@ -20,6 +21,38 @@ _CASH_CACHE_THREADS: dict[str, threading.Thread] = {}
_CASH_CACHE_THREADS_LOCK = threading.Lock()
_CASH_CACHE_CANCEL_REQUESTS: set[str] = set()
_CASH_CACHE_STARTING: dict[str, dict[str, Any]] = {}
_CASH_PROGRESS_FILE = Path(__file__).resolve().parents[1] / "data" / "cash_payment_progress.json"
def _cash_progress_snapshot(payload: dict[str, Any]) -> None:
"""Publish one lightweight cash-job snapshot for the local dashboard.
This file is presentation state only. The authoritative extraction state
remains in the client .act SQLite database.
"""
try:
_CASH_PROGRESS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = _CASH_PROGRESS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, default=str), encoding="utf-8")
tmp.replace(_CASH_PROGRESS_FILE)
except Exception:
pass
def _cash_stage_percent(status: str, stage: str, completed: int, total: int) -> int:
status_l = str(status or "").lower()
stage_l = str(stage or "").lower()
if status_l == "completed":
return 100
if "analysis" in stage_l:
return 95
if "ledger" in stage_l:
return 15
if "connect" in stage_l or status_l in {"starting", "queued"}:
return 5
if "cooling before" in stage_l:
return 20
if total > 0:
return min(92, 20 + int(round(max(0, completed) * 72 / max(1, total))))
return 10
def is_tally_cache_busy() -> bool:
return _CASH_CACHE_BUSY.is_set()
@@ -71,6 +104,8 @@ class AgentCommandProcessor:
result = self._bank_posting_preflight(payload)
elif action == "accounting_bank_reconciliation_extract":
result = self._bank_reconciliation_extract(payload)
elif action == "accounting_cash_payment_ledgers":
result = self._cash_payment_ledgers(payload)
elif action == "accounting_cash_payment_cache_start":
result = self._cash_payment_cache_start(payload)
elif action == "accounting_cash_payment_cache_status":
@@ -129,6 +164,8 @@ class AgentCommandProcessor:
"cash_payment_compliance_capability": True,
"cash_payment_sqlite_cache_capability": True,
"cash_payment_background_sync_capability": True,
"cash_payment_ledger_confirmation_capability": True,
"cash_payment_dual_progress_capability": True,
"tally_writeback_capability": True,
}
@@ -669,6 +706,35 @@ class AgentCommandProcessor:
}
def _cash_payment_ledgers(self, payload: dict[str, Any]) -> dict[str, Any]:
company, company_name = self._resolve_open_company(payload)
rows = self.tally.export_master_collection(company_name, "ledgers") or []
ledgers = []
for row in rows:
name = str(row.get("name") or "").strip()
if not name:
continue
parent = str(row.get("parent") or "").strip()
reserved = str(row.get("reserved_name") or "").strip()
parent_key = parent.casefold().replace("-", " ")
is_cash = parent_key == "cash in hand" or reserved.casefold() == "cash" or name.casefold() == "cash"
ledgers.append({
"name": name,
"parent": parent,
"reserved_name": reserved,
"closing_balance": row.get("closing_balance"),
"is_cash_candidate": bool(is_cash),
})
ledgers.sort(key=lambda x: (not x["is_cash_candidate"], x["name"].casefold()))
return {
"company": company,
"company_name": company_name,
"ledgers": ledgers,
"cash_candidates": [x for x in ledgers if x["is_cash_candidate"]],
"ledger_count": len(ledgers),
"agent": self._agent_info(),
}
def _ensure_cash_cache_schema(self, client_id: int) -> None:
with self.store.connect(client_id) as db:
db.executescript(
@@ -711,6 +777,16 @@ class AgentCommandProcessor:
);
CREATE INDEX IF NOT EXISTS ix_cash_cache_days_status
ON cash_payment_cache_days(job_id, status, voucher_date);
CREATE TABLE IF NOT EXISTS cash_payment_cache_vouchers (
job_id TEXT NOT NULL,
voucher_date TEXT NOT NULL,
voucher_key TEXT NOT NULL,
voucher_json TEXT NOT NULL,
PRIMARY KEY(job_id, voucher_key),
FOREIGN KEY(job_id) REFERENCES cash_payment_cache_jobs(job_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_cash_cache_vouchers_date
ON cash_payment_cache_vouchers(job_id, voucher_date);
"""
)
@@ -737,6 +813,28 @@ class AgentCommandProcessor:
sql = "UPDATE cash_payment_cache_jobs SET " + ", ".join(f"{name}=?" for name in cols) + " WHERE job_id=? AND client_id=?"
with self.store.connect(client_id) as db:
db.execute(sql, [values[name] for name in cols] + [job_id, client_id])
try:
row = self._cash_cache_job_row(client_id, job_id) or {}
total = max(1, int(row.get("total_days") or 1))
completed = int(row.get("completed_days") or 0)
status = str(row.get("status") or "")
stage = str(row.get("stage") or "")
settings = json.loads(str(row.get("settings_json") or "{}"))
cash_ledgers = json.loads(str(row.get("cash_ledgers_json") or "[]"))
_cash_progress_snapshot({
"job_id": job_id, "client_id": client_id, "company_name": row.get("company_name") or "",
"status": status, "stage": stage, "current_date": row.get("current_date") or "",
"date_from": row.get("date_from") or "", "date_to": row.get("date_to") or "",
"total_days": total, "completed_days": completed,
"percent": _cash_stage_percent(status, stage, completed, total),
"vouchers_cached": int(row.get("vouchers_cached") or 0),
"tally_requests": int(row.get("tally_requests") or 0),
"retry_count": int(row.get("retry_count") or 0),
"cash_ledgers": cash_ledgers, "cash_limit": settings.get("cash_limit"),
"error": row.get("error_message") or "", "updated_at_utc": row.get("updated_at_utc"),
})
except Exception:
pass
def _cash_payment_cache_start(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id"))
@@ -745,6 +843,13 @@ class AgentCommandProcessor:
date_to = str(payload.get("date_to") or "").strip()
if not tally_guid:
raise ValueError("Select a mapped Tally company before starting extraction.")
selected_cash_ledgers = [str(x or "").strip() for x in (payload.get("cash_ledger_names") or []) if str(x or "").strip()]
single_cash_ledger = str(payload.get("cash_ledger_name") or "").strip()
if single_cash_ledger and single_cash_ledger not in selected_cash_ledgers:
selected_cash_ledgers.append(single_cash_ledger)
if not selected_cash_ledgers:
raise ValueError("Load the Tally ledgers and confirm the Cash ledger before starting extraction.")
payload["cash_ledger_names"] = selected_cash_ledgers
try:
start_day = _dt_date.fromisoformat(date_from)
end_day = _dt_date.fromisoformat(date_to)
@@ -784,12 +889,14 @@ class AgentCommandProcessor:
"sqlite_cache": True,
"can_resume": False,
"can_cancel": True,
"cash_ledgers": list(selected_cash_ledgers),
}
with _CASH_CACHE_THREADS_LOCK:
if _CASH_CACHE_BUSY.is_set() or any(t.is_alive() for t in _CASH_CACHE_THREADS.values()):
raise ValueError("Another Tally extraction is already running on this workstation. Please wait for it to finish.")
_CASH_CACHE_STARTING[job_id] = dict(starting)
_CASH_CACHE_CANCEL_REQUESTS.discard(job_id)
_cash_progress_snapshot(dict(starting))
worker_payload = dict(payload)
worker_payload["tally_pause_seconds"] = pause_seconds
@@ -818,6 +925,7 @@ class AgentCommandProcessor:
"split_window_days": int(payload.get("split_window_days") or 3),
"near_limit_percent": float(payload.get("near_limit_percent") or 80.0),
"requested_by_user_id": payload.get("requested_by_user_id"),
"cash_ledger_names": [str(x or "").strip() for x in (payload.get("cash_ledger_names") or []) if str(x or "").strip()],
}
now = datetime.now(timezone.utc).isoformat()
try:
@@ -845,7 +953,10 @@ class AgentCommandProcessor:
(client_id, tally_guid, date_from, date_to),
).fetchone()
prior = dict(prior) if prior else None
resume = bool(prior and str(prior.get("status") or "") in {"failed", "paused", "interrupted", "running", "cooling", "cancelled"})
selected_keys = sorted(x.casefold() for x in settings.get("cash_ledger_names") or [])
prior_settings = json.loads(str(prior.get("settings_json") or "{}")) if prior else {}
prior_keys = sorted(str(x).casefold() for x in (prior_settings.get("cash_ledger_names") or []))
resume = bool(prior and selected_keys == prior_keys and str(prior.get("status") or "") in {"failed", "paused", "interrupted", "running", "cooling", "cancelled"})
if resume:
db_job_id = str(prior.get("job_id"))
# Keep the instant-ACK id stable in the UI by copying the
@@ -868,6 +979,14 @@ class AgentCommandProcessor:
"INSERT INTO cash_payment_cache_days(job_id, voucher_date, status, voucher_count, attempts, last_error, updated_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?)",
[(job_id, r[0], r[1], int(r[2] or 0), int(r[3] or 0), r[4], now) for r in rows],
)
cached_vouchers = db.execute(
"SELECT voucher_date, voucher_key, voucher_json FROM cash_payment_cache_vouchers WHERE job_id=?",
(db_job_id,),
).fetchall()
db.executemany(
"INSERT OR REPLACE INTO cash_payment_cache_vouchers(job_id, voucher_date, voucher_key, voucher_json) VALUES (?, ?, ?, ?)",
[(job_id, r[0], r[1], r[2]) for r in cached_vouchers],
)
else:
db.execute(
"""INSERT INTO cash_payment_cache_jobs(
@@ -944,16 +1063,14 @@ class AgentCommandProcessor:
time.sleep(15.0)
if masters is None:
raise ValueError(f"TallyPrime did not return ledger masters after a slow retry. Details: {master_error}")
cash_ledgers = set()
for ledger in masters:
name = str(ledger.get("name") or "").strip()
parent = str(ledger.get("parent") or "").strip().casefold().replace("-", " ")
reserved = str(ledger.get("reserved_name") or "").strip().casefold()
if name and (parent == "cash in hand" or reserved == "cash" or name.casefold() == "cash"):
cash_ledgers.add(name.casefold())
if not cash_ledgers:
cash_ledgers.add("cash")
self._cash_cache_update(client_id, job_id, cash_ledgers_json=json.dumps(sorted(cash_ledgers)), stage="Cooling before voucher extraction")
master_by_key = {str(x.get("name") or "").strip().casefold(): str(x.get("name") or "").strip() for x in masters if str(x.get("name") or "").strip()}
requested_ledgers = [str(x or "").strip() for x in (payload.get("cash_ledger_names") or []) if str(x or "").strip()]
missing = [x for x in requested_ledgers if x.casefold() not in master_by_key]
if missing:
raise ValueError("The confirmed Cash ledger is no longer available in TallyPrime: " + ", ".join(missing))
cash_ledgers = {x.casefold() for x in requested_ledgers}
confirmed_names = [master_by_key[x.casefold()] for x in requested_ledgers]
self._cash_cache_update(client_id, job_id, cash_ledgers_json=json.dumps(confirmed_names), stage="Cash ledger confirmed; cooling before voucher extraction")
time.sleep(max(5.0, pause_seconds))
with self.store.connect(client_id) as db:
@@ -993,16 +1110,25 @@ class AgentCommandProcessor:
)
raise ValueError(f"Extraction paused at {day} because TallyPrime did not answer two deliberately slow one-day requests. Restart/verify TallyPrime and run again to resume from this date. Details: {last_exc}")
self.store.replace_transaction_snapshot(
client_id,
mapping=mapping,
transactions={"date_from": day, "date_to": day, "vouchers": list(vouchers)},
requested_by_user_id=int(requested_by) if requested_by not in (None, "") else None,
)
relevant_vouchers = []
for voucher in vouchers:
entries = list(voucher.get("ledger_entries") or [])
if any(str(entry.get("ledger_name") or "").strip().casefold() in cash_ledgers for entry in entries):
relevant_vouchers.append(voucher)
now_day = datetime.now(timezone.utc).isoformat()
with self.store.connect(client_id) as db:
db.execute("DELETE FROM cash_payment_cache_vouchers WHERE job_id=? AND voucher_date=?", (job_id, day))
for idx, voucher in enumerate(relevant_vouchers):
key = str(voucher.get("guid") or voucher.get("master_id") or voucher.get("voucher_key") or "").strip()
if not key:
key = f"{day}|{voucher.get('voucher_type_name') or ''}|{voucher.get('voucher_number') or ''}|{idx}"
db.execute(
"INSERT OR REPLACE INTO cash_payment_cache_vouchers(job_id, voucher_date, voucher_key, voucher_json) VALUES (?, ?, ?, ?)",
(job_id, day, key, json.dumps(voucher, ensure_ascii=False, default=str)),
)
db.execute(
"UPDATE cash_payment_cache_days SET status='completed', voucher_count=?, attempts=attempts+1, last_error=NULL, updated_at_utc=? WHERE job_id=? AND voucher_date=?",
(len(vouchers), datetime.now(timezone.utc).isoformat(), job_id, day),
(len(relevant_vouchers), now_day, job_id, day),
)
agg = db.execute(
"SELECT COUNT(*), COALESCE(SUM(voucher_count),0) FROM cash_payment_cache_days WHERE job_id=? AND status='completed'",
@@ -1055,12 +1181,22 @@ class AgentCommandProcessor:
raise ValueError("Cash payment cache job was not found on this workstation.")
total = max(1, int(row.get("total_days") or 1))
completed = int(row.get("completed_days") or 0)
percent = min(100, int(round(completed * 100 / total)))
status_value = str(row.get("status") or "")
stage_value = str(row.get("stage") or "")
percent = _cash_stage_percent(status_value, stage_value, completed, total)
try:
settings = json.loads(str(row.get("settings_json") or "{}"))
except Exception:
settings = {}
try:
selected_cash_ledgers = json.loads(str(row.get("cash_ledgers_json") or "[]"))
except Exception:
selected_cash_ledgers = []
return {
"job": {
"job_id": row["job_id"],
"status": row.get("status"),
"stage": row.get("stage"),
"status": status_value,
"stage": stage_value,
"current_date": row.get("current_date"),
"date_from": row.get("date_from"),
"date_to": row.get("date_to"),
@@ -1071,6 +1207,9 @@ class AgentCommandProcessor:
"tally_requests": int(row.get("tally_requests") or 0),
"retry_count": int(row.get("retry_count") or 0),
"pause_seconds": float(row.get("pause_seconds") or 0),
"company_name": row.get("company_name") or "",
"cash_ledgers": selected_cash_ledgers,
"cash_limit": settings.get("cash_limit"),
"error": row.get("error_message") or "",
"started_at_utc": row.get("started_at_utc"),
"updated_at_utc": row.get("updated_at_utc"),
@@ -1100,27 +1239,21 @@ class AgentCommandProcessor:
cash_limit = round(float(payload.get("cash_limit") or settings.get("cash_limit") or 10000.0), 2)
split_window_days = int(payload.get("split_window_days") or settings.get("split_window_days") or 3)
near_limit_percent = float(payload.get("near_limit_percent") or settings.get("near_limit_percent") or 80.0)
cash_ledgers = set(json.loads(str(row.get("cash_ledgers_json") or "[]"))) or {"cash"}
cash_ledger_names = list(json.loads(str(row.get("cash_ledgers_json") or "[]"))) or ["Cash"]
cash_ledgers = {str(x or "").strip().casefold() for x in cash_ledger_names if str(x or "").strip()} or {"cash"}
self._cash_cache_update(client_id, job_id, stage="Analysing confirmed Cash-ledger transactions")
with self.store.connect(client_id) as db:
voucher_rows = db.execute(
"""SELECT * FROM tally_vouchers WHERE tally_guid=? AND voucher_date>=? AND voucher_date<=?
ORDER BY voucher_date, id""",
(tally_guid, date_from, date_to),
cached_rows = db.execute(
"SELECT voucher_json FROM cash_payment_cache_vouchers WHERE job_id=? ORDER BY voucher_date, voucher_key",
(job_id,),
).fetchall()
vouchers = []
for vr in voucher_rows:
entries = db.execute(
"SELECT ledger_name, amount, is_deemed_positive FROM tally_voucher_ledger_entries WHERE voucher_id=? ORDER BY line_no",
(int(vr["id"]),),
).fetchall()
vouchers.append({
"date": vr["voucher_date"], "effective_date": vr["effective_date"],
"voucher_number": vr["voucher_number"], "voucher_type_name": vr["voucher_type_name"],
"reference": vr["reference"], "narration": vr["narration"], "party_ledger_name": vr["party_ledger_name"],
"is_cancelled": vr["is_cancelled"], "is_optional": vr["is_optional"],
"ledger_entries": [dict(e) for e in entries],
})
vouchers = []
for cached in cached_rows:
try:
vouchers.append(json.loads(str(cached[0] or "{}")))
except Exception:
continue
payments = []
for voucher in vouchers:
@@ -1197,11 +1330,12 @@ class AgentCommandProcessor:
split_patterns.append({"party": party_name, "date_from": window[0][0].isoformat(), "date_to": window[-1][0].isoformat(), "total_amount": total, "days": [{"date": d.isoformat(), "amount": a} for d, a in window], "review_only": True})
exceptions.sort(key=lambda x: (x["date"], x["party"].casefold()))
split_patterns.sort(key=lambda x: (x["date_from"], x["party"].casefold()))
self._cash_cache_update(client_id, job_id, status="completed", stage="Cash-payment analysis complete")
return {
"cash_payment_review": {
"company_name": row.get("company_name"), "company_guid": tally_guid,
"date_from": date_from, "date_to": date_to, "cash_limit": cash_limit,
"cash_ledgers": sorted(cash_ledgers),
"cash_ledgers": cash_ledger_names,
"summary": {"vouchers_reviewed": len(vouchers), "cash_payment_vouchers": len(payments), "single_voucher_exceptions": single_count, "same_day_exceptions": same_day_count, "possible_split_patterns": len(split_patterns)},
"exceptions": exceptions, "possible_split_payments": split_patterns, "cash_payments": payments,
"read_only": True, "sqlite_cache": True, "background_extraction": True,
@@ -56,6 +56,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())
self.send_error(404)
def do_POST(self):
@@ -122,6 +124,17 @@ 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"
if not path.exists():
return {"active": False, "job": None}
try:
job = json.loads(path.read_text(encoding="utf-8")) or {}
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}
def history(self) -> dict[str, Any]:
return {
"connections": self.db.recent_rows("connection_history", 20),
@@ -136,7 +149,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}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}@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}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}}
</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">
@@ -149,6 +162,7 @@ body{font-family:Segoe UI,Arial,sans-serif;background:#f4f6f8;color:#17202a;marg
<button id="check" onclick="checkUpdate()">Check for Update</button><button class="secondary" id="download" onclick="downloadUpdate()">Download Update</button><button id="install" onclick="installUpdate()">Install & Restart Safely</button><div id="message"></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" 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"><h2>Recent History</h2><div id="history" class="muted">Loading…</div></div>
</div>
<script>
@@ -156,6 +170,8 @@ 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 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';
@@ -164,7 +180,7 @@ let te=document.getElementById('tally');let known=s.tally.connected!==null;te.te
document.getElementById('storage').textContent=gb(s.storage.free_bytes);document.getElementById('storageRoot').textContent=s.storage.root;
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();}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 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)}}}
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)}}