Add cash ledger extraction and dual progress UI
This commit is contained in:
@@ -4,7 +4,7 @@ import io
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
ERP_LOCAL_AGENT_VERSION = "1.22.13"
|
||||
ERP_LOCAL_AGENT_VERSION = "1.22.14"
|
||||
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)
|
||||
|
||||
@@ -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=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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)}}
|
||||
|
||||
Reference in New Issue
Block a user