Complete Phase 1 Tally UI and local agent integration

This commit is contained in:
A R R R Associates
2026-08-18 14:39:19 +05:30
parent b262861608
commit 2ecaa0b088
15 changed files with 837 additions and 7 deletions
+3
View File
@@ -333,6 +333,9 @@ ROLE_PERMISSION_MAP = {
"documents.upload", "documents.upload",
"documents.download", "documents.download",
"documents.delete", "documents.delete",
"accounting.tally.view",
"accounting.tally.connect",
"accounting.act.initialize",
"clients.view.own_only", "clients.view.own_only",
"employees.dashboard.view", "employees.dashboard.view",
"employees.view", "employees.view",
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import tempfile
import time
from typing import Any
from uuid import uuid4
COMMAND_TIMEOUT_SECONDS = 25
COMMAND_MAX_AGE_SECONDS = 120
def _bus_root() -> Path:
configured = (os.getenv("ERP_AGENT_COMMAND_BUS_ROOT") or "").strip()
if configured:
root = Path(configured).expanduser().resolve()
else:
root = Path(tempfile.gettempdir()) / "arrr_erp_agent_command_bus"
root.mkdir(parents=True, exist_ok=True)
return root
def _safe_node_code(node_code: str) -> str:
return "".join(ch for ch in str(node_code or "") if ch.isalnum() or ch in "-_.")[:100]
def _node_dir(node_code: str) -> Path:
safe = _safe_node_code(node_code)
if not safe:
raise ValueError("Storage node code is required.")
path = _bus_root() / safe
path.mkdir(parents=True, exist_ok=True)
return path
def _atomic_json_write(path: Path, payload: dict[str, Any]) -> None:
temp = path.with_suffix(path.suffix + ".tmp")
temp.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
temp.replace(path)
def _read_json(path: Path) -> dict[str, Any] | None:
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else None
except Exception:
return None
def _cleanup(node_code: str) -> None:
now = time.time()
for path in _node_dir(node_code).glob("*.json"):
try:
if now - path.stat().st_mtime > COMMAND_MAX_AGE_SECONDS:
path.unlink(missing_ok=True)
except Exception:
pass
def enqueue_agent_command(node_code: str, action: str, payload: dict[str, Any] | None = None) -> str:
_cleanup(node_code)
command_id = uuid4().hex
body = {
"command_id": command_id,
"action": str(action or "").strip(),
"payload": payload or {},
"created_at_epoch": time.time(),
}
_atomic_json_write(_node_dir(node_code) / f"command_{command_id}.json", body)
return command_id
def list_pending_agent_commands(node_code: str, limit: int = 10) -> list[dict[str, Any]]:
_cleanup(node_code)
base = _node_dir(node_code)
items: list[dict[str, Any]] = []
for path in sorted(base.glob("command_*.json"), key=lambda p: p.stat().st_mtime):
command_id = path.stem.removeprefix("command_")
if (base / f"response_{command_id}.json").exists():
continue
payload = _read_json(path)
if payload:
items.append(payload)
if len(items) >= max(1, int(limit)):
break
return items
def record_agent_command_result(node_code: str, result: dict[str, Any]) -> None:
command_id = str(result.get("command_id") or "").strip()
if not command_id:
return
response = {
"command_id": command_id,
"ok": bool(result.get("ok")),
"result": result.get("result"),
"error": result.get("error"),
"agent_time_utc": result.get("agent_time_utc"),
"received_at_epoch": time.time(),
}
_atomic_json_write(_node_dir(node_code) / f"response_{command_id}.json", response)
def wait_for_agent_result(node_code: str, command_id: str, timeout_seconds: int = COMMAND_TIMEOUT_SECONDS) -> dict[str, Any]:
base = _node_dir(node_code)
response_path = base / f"response_{command_id}.json"
command_path = base / f"command_{command_id}.json"
deadline = time.monotonic() + max(1, int(timeout_seconds))
while time.monotonic() < deadline:
result = _read_json(response_path)
if result:
try:
response_path.unlink(missing_ok=True)
command_path.unlink(missing_ok=True)
except Exception:
pass
return result
time.sleep(0.2)
raise TimeoutError("ERP Local Agent did not respond before the command timeout.")
def request_agent_command(
node_code: str,
action: str,
payload: dict[str, Any] | None = None,
timeout_seconds: int = COMMAND_TIMEOUT_SECONDS,
) -> dict[str, Any]:
command_id = enqueue_agent_command(node_code, action, payload)
return wait_for_agent_result(node_code, command_id, timeout_seconds=timeout_seconds)
@@ -0,0 +1,105 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Tally Connection</h1>
<p class="mt-1 text-sm text-slate-500">Read-only Phase 1 connection through the existing ERP Local Agent. Tally port 9000 is never exposed to the internet.</p>
</div>
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% else %}?refresh=1{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Test Tally Connection</a>
</div>
{% if initialized %}
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">Client accounting storage was initialized successfully.</div>
{% endif %}
{% if command_error %}
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{{ command_error }}</div>
{% endif %}
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl bg-white p-5 shadow-soft">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">ERP Local Agent</div>
<div class="mt-2 text-lg font-semibold {% if agent_online %}text-emerald-700{% else %}text-red-700{% endif %}">{% if agent_online %}Connected{% else %}Offline{% endif %}</div>
<div class="mt-1 text-xs text-slate-500">{{ storage_node.node_name if storage_node else 'No active branch agent' }}</div>
</div>
<div class="rounded-2xl bg-white p-5 shadow-soft">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Tally Module</div>
{% set agent = live_result.agent if live_result else None %}
<div class="mt-2 text-lg font-semibold {% if agent and agent.tally_capability %}text-emerald-700{% else %}text-slate-700{% endif %}">{{ 'Available' if agent and agent.tally_capability else ('Check connection' if agent_online else 'Unavailable') }}</div>
<div class="mt-1 text-xs text-slate-500">Agent {{ agent.version if agent and agent.version else '-' }}</div>
</div>
<div class="rounded-2xl bg-white p-5 shadow-soft">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">TallyPrime</div>
{% set tally = live_result.tally if live_result else None %}
<div class="mt-2 text-lg font-semibold {% if tally and tally.connected %}text-emerald-700{% elif tally %}text-red-700{% else %}text-slate-700{% endif %}">{{ 'Connected' if tally and tally.connected else ('Not connected' if tally else 'Not checked') }}</div>
<div class="mt-1 text-xs text-slate-500">{{ tally.url if tally and tally.url else '127.0.0.1:9000' }}</div>
</div>
<div class="rounded-2xl bg-white p-5 shadow-soft">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Loaded Companies</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ tally.company_count if tally else '-' }}</div>
<div class="mt-1 text-xs text-slate-500">Read-only discovery</div>
</div>
</section>
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="flex flex-wrap items-end gap-4">
<form method="get" action="/tools/tally" class="flex flex-1 flex-wrap items-end gap-3">
<label class="min-w-[280px] flex-1 text-sm font-medium text-slate-700">
Client
<select name="client_id" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
<option value="">Select client</option>
{% for client in clients %}<option value="{{ client.id }}" {% if selected_client and selected_client.id == client.id %}selected{% endif %}>{{ client.client_name }}</option>{% endfor %}
</select>
</label>
<button type="submit" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Open Client Storage</button>
</form>
{% if selected_client %}
<form method="post" action="/tools/tally/initialize">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
<button type="submit" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800">Create / Verify .act Storage</button>
</form>
{% endif %}
</div>
<p class="mt-3 text-xs text-slate-500">Only clients assigned to the logged-in Partner in the active branch are shown.</p>
</section>
{% if tally %}
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-100 px-5 py-4">
<h2 class="font-semibold text-slate-900">Loaded Tally Companies</h2>
<p class="text-sm text-slate-500">Company name, GUID and GSTIN returned directly by the local TallyPrime instance.</p>
</div>
{% if tally.companies %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left font-semibold text-slate-600">Company</th><th class="px-4 py-3 text-left font-semibold text-slate-600">GUID</th><th class="px-4 py-3 text-left font-semibold text-slate-600">GSTIN</th></tr></thead>
<tbody class="divide-y divide-slate-100">{% for company in tally.companies %}<tr><td class="px-4 py-3 font-medium text-slate-900">{{ company.name }}</td><td class="px-4 py-3 text-slate-600">{{ company.guid or '-' }}</td><td class="px-4 py-3 text-slate-600">{{ company.gstin or '-' }}</td></tr>{% endfor %}</tbody>
</table>
</div>
{% else %}
<div class="p-6 text-sm text-slate-500">TallyPrime responded, but no loaded company was returned.</div>
{% endif %}
</section>
{% endif %}
{% if selected_client %}
{% set accounting = live_result.accounting if live_result else None %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="flex flex-wrap items-start justify-between gap-4">
<div><h2 class="font-semibold text-slate-900">Accounting Storage</h2><p class="mt-1 text-sm text-slate-500">{{ selected_client.client_name }}</p></div>
<span class="rounded-full px-3 py-1 text-xs font-semibold {% if accounting and accounting.exists %}bg-emerald-50 text-emerald-700{% else %}bg-amber-50 text-amber-700{% endif %}">{{ 'Initialized' if accounting and accounting.exists else 'Not initialized / not checked' }}</span>
</div>
{% if accounting and accounting.exists %}
<dl class="mt-4 grid gap-4 md:grid-cols-2">
<div><dt class="text-xs font-semibold uppercase tracking-wide text-slate-500">.act Database</dt><dd class="mt-1 break-all text-sm font-medium text-slate-900">{{ accounting.db_path }}</dd></div>
<div><dt class="text-xs font-semibold uppercase tracking-wide text-slate-500">Schema Version</dt><dd class="mt-1 text-sm font-medium text-slate-900">{{ accounting.metadata.schema_version or '1' }}</dd></div>
</dl>
{% endif %}
</section>
{% endif %}
<div class="rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">Phase 1 is read-only. No ledger, voucher, inventory or write-back operation is performed.</div>
</div>
{% endblock %}
+174
View File
@@ -0,0 +1,174 @@
from __future__ import annotations
from datetime import datetime, timezone
from urllib.parse import quote
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.security.session_auth import get_current_user
from app.core.templating import templates
from app.modules.clients.models import Client
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.core.rbac.permission_guard import require_permission
from app.modules.documents.services import build_document_scope, get_active_storage_node_for_branch
from app.modules.accounting.agent_bridge import request_agent_command
router = APIRouter(prefix="/tools/tally", tags=["accounting-tally-ui"])
def _denied():
from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _require_partner(request: Request, db, permission: str):
user = get_current_user(request, db)
if not user:
return None, RedirectResponse(url="/login", status_code=303)
roles = set(get_user_roles(db, user.id))
if "Partner" not in roles:
return None, _denied()
try:
require_permission(db, user, permission)
except Exception:
return None, _denied()
return user, None
def _visible_clients(db, request: Request, user):
scope = build_document_scope(request, db, user)
stmt = select(Client).where(Client.tenant_id == scope.tenant_id, Client.partner_id == user.id)
if scope.branch_id is not None:
stmt = stmt.where(Client.branch_id == scope.branch_id)
return db.execute(stmt.order_by(Client.client_name.asc(), Client.id.asc())).scalars().all(), scope
def _find_visible_client(db, request: Request, user, client_id: int):
clients, scope = _visible_clients(db, request, user)
client = next((row for row in clients if int(row.id) == int(client_id)), None)
return client, clients, scope
def _node_online(node) -> bool:
if not node or not node.last_seen_at_utc:
return False
seen = node.last_seen_at_utc
if seen.tzinfo is None:
seen = seen.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - seen).total_seconds() <= 180
def _render(request: Request, db, user, **context):
base = {
"request": request,
"current_user": user,
"current_user_roles": get_user_roles(db, user.id),
"current_user_permissions": get_user_permissions(db, user.id),
"csrf_token": get_or_create_csrf_token(request),
}
base.update(context)
return templates.TemplateResponse(
"modules/accounting/templates/accounting/tally.html",
base,
)
@router.get("")
def tally_tool(request: Request, client_id: int | None = None, refresh: int = 0, initialized: int = 0, error: str = ""):
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.view")
if response:
return response
clients, scope = _visible_clients(db, request, user)
selected_client = next((row for row in clients if client_id and int(row.id) == int(client_id)), None)
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
online = _node_online(node)
live_result = None
command_error = error or ""
if refresh:
try:
require_permission(db, user, "accounting.tally.connect")
except Exception:
return _denied()
if refresh and node and online:
payload = {}
if selected_client:
payload = {
"client_id": int(selected_client.id),
"client_name": selected_client.client_name,
}
try:
response_data = request_agent_command(node.node_code, "phase1_status", payload, timeout_seconds=20)
if response_data.get("ok"):
live_result = response_data.get("result") or {}
else:
command_error = str(response_data.get("error") or "Local agent command failed.")
except Exception as exc:
command_error = str(exc)
return _render(
request,
db,
user,
title="Tally Connection",
clients=clients,
selected_client=selected_client,
storage_node=node,
agent_online=online,
live_result=live_result,
initialized=bool(initialized),
command_error=command_error,
)
finally:
db.close()
@router.post("/initialize")
def initialize_accounting_storage(
request: Request,
client_id: int = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.act.initialize")
if response:
return response
client, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client:
return _denied()
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,
)
try:
result = request_agent_command(
node.node_code,
"accounting_initialize",
{"client_id": int(client.id), "client_name": client.client_name},
timeout_seconds=20,
)
if not result.get("ok"):
raise RuntimeError(str(result.get("error") or "Accounting storage initialization failed."))
except Exception as exc:
return RedirectResponse(
url=f"/tools/tally?client_id={client.id}&error={quote(str(exc))}",
status_code=303,
)
return RedirectResponse(
url=f"/tools/tally?client_id={client.id}&refresh=1&initialized=1",
status_code=303,
)
finally:
db.close()
@@ -147,6 +147,10 @@ PERMISSIONS = {
"documents.delete": "Archive Engagement Documents", "documents.delete": "Archive Engagement Documents",
"documents.audit.view": "View Document Access Logs", "documents.audit.view": "View Document Access Logs",
"accounting.tally.view": "View Tally Accounting Tool",
"accounting.tally.connect": "Connect to Tally Through Local Agent",
"accounting.act.initialize": "Initialize Client Accounting ACT Storage",
"notice_cases.view": "View Notice and Case Management", "notice_cases.view": "View Notice and Case Management",
"notice_cases.create": "Create Notices and Cases", "notice_cases.create": "Create Notices and Cases",
"notice_cases.edit": "Edit Notices and Cases", "notice_cases.edit": "Edit Notices and Cases",
+1 -1
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.0.0" ERP_LOCAL_AGENT_VERSION = "1.1.0"
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"
@@ -1,2 +1,2 @@
__version__ = "1.0.0" __version__ = "1.1.0"
AGENT_NAME = "ERP Local Agent" AGENT_NAME = "ERP Local Agent"
@@ -0,0 +1,170 @@
from __future__ import annotations
from datetime import datetime, timezone
import json
from pathlib import Path
import sqlite3
from typing import Sequence
SCHEMA_VERSION = "1"
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class LocalAccountingStore:
"""Client-scoped SQLite .act storage under the existing branch storage root."""
def __init__(self, storage_root: Path):
self.root = Path(storage_root).resolve() / "Accounting"
self.root.mkdir(parents=True, exist_ok=True)
@staticmethod
def _client_key(client_id: int) -> str:
return f"client_{int(client_id):08d}"
def client_dir(self, client_id: int) -> Path:
return self.root / self._client_key(client_id)
def db_path(self, client_id: int) -> Path:
key = self._client_key(client_id)
return self.client_dir(client_id) / f"{key}.act"
def exists(self, client_id: int) -> bool:
return self.db_path(client_id).is_file()
def connect(self, client_id: int):
path = self.db_path(client_id)
path.parent.mkdir(parents=True, exist_ok=True)
db = sqlite3.connect(path)
db.row_factory = sqlite3.Row
return db
def initialize(self, client_id: int, client_name: str = "") -> Path:
path = self.db_path(client_id)
with self.connect(client_id) as db:
db.executescript(
"""
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS act_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tally_companies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tally_guid TEXT NOT NULL DEFAULT '',
company_name TEXT NOT NULL,
gstin TEXT NOT NULL DEFAULT '',
first_seen_at_utc TEXT NOT NULL,
last_seen_at_utc TEXT NOT NULL,
is_currently_loaded INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS ix_tally_companies_guid ON tally_companies(tally_guid);
CREATE INDEX IF NOT EXISTS ix_tally_companies_name ON tally_companies(company_name);
CREATE TABLE IF NOT EXISTS tally_company_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL,
registration_id INTEGER NULL,
tally_guid TEXT NOT NULL,
company_name TEXT NOT NULL,
gstin TEXT NOT NULL DEFAULT '',
is_active INTEGER NOT NULL DEFAULT 1,
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tally_connection_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
checked_at_utc TEXT NOT NULL,
connected INTEGER NOT NULL,
tally_url TEXT NOT NULL DEFAULT '',
company_count INTEGER NOT NULL DEFAULT 0,
error_message TEXT NULL,
payload_json TEXT NULL
);
CREATE TABLE IF NOT EXISTS tally_sync_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sync_type TEXT NOT NULL,
status TEXT NOT NULL,
started_at_utc TEXT NOT NULL,
completed_at_utc TEXT NULL,
rows_processed INTEGER NOT NULL DEFAULT 0,
error_message TEXT NULL,
details_json TEXT NULL
);
"""
)
now = _utc_now_iso()
meta = {
"schema_version": SCHEMA_VERSION,
"client_id": str(int(client_id)),
"client_name": str(client_name or "").strip(),
"storage_kind": "client_accounting_act",
}
for key, value in meta.items():
db.execute(
"""INSERT INTO act_meta(key, value, updated_at_utc) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at_utc=excluded.updated_at_utc""",
(key, value, now),
)
return path
def record_tally_status(self, client_id: int, status: dict) -> None:
if not self.exists(client_id):
return
now = _utc_now_iso()
companies: Sequence[dict] = status.get("companies") or []
with self.connect(client_id) as db:
db.execute("UPDATE tally_companies SET is_currently_loaded=0")
for company in companies:
name = str(company.get("name") or "").strip()
if not name:
continue
guid = str(company.get("guid") or "").strip()
gstin = str(company.get("gstin") or "").strip().upper()
existing = db.execute(
"SELECT id FROM tally_companies WHERE tally_guid=? AND company_name=? LIMIT 1",
(guid, name),
).fetchone()
if existing:
db.execute(
"UPDATE tally_companies SET gstin=?, last_seen_at_utc=?, is_currently_loaded=1 WHERE id=?",
(gstin, now, int(existing["id"])),
)
else:
db.execute(
"""INSERT INTO tally_companies(tally_guid, company_name, gstin, first_seen_at_utc, last_seen_at_utc, is_currently_loaded)
VALUES (?, ?, ?, ?, ?, 1)""",
(guid, name, gstin, now, now),
)
db.execute(
"""INSERT INTO tally_connection_history(checked_at_utc, connected, tally_url, company_count, error_message, payload_json)
VALUES (?, ?, ?, ?, ?, ?)""",
(
now,
1 if status.get("connected") else 0,
str(status.get("url") or ""),
int(status.get("company_count") or 0),
str(status.get("error") or "") or None,
json.dumps(status, ensure_ascii=False, separators=(",", ":")),
),
)
def snapshot(self, client_id: int) -> dict:
path = self.db_path(client_id)
if not path.is_file():
return {"exists": False, "db_path": str(path), "metadata": {}, "latest_connection": None}
with self.connect(client_id) as db:
meta_rows = db.execute("SELECT key, value FROM act_meta ORDER BY key").fetchall()
latest = db.execute(
"SELECT checked_at_utc, connected, tally_url, company_count, error_message FROM tally_connection_history ORDER BY id DESC LIMIT 1"
).fetchone()
return {
"exists": True,
"db_path": str(path),
"metadata": {row["key"]: row["value"] for row in meta_rows},
"latest_connection": dict(latest) if latest else None,
}
@@ -0,0 +1,79 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from . import __version__
from .accounting_store import LocalAccountingStore
from .tally import TallyLiveConnector
class AgentCommandProcessor:
def __init__(self, config, logger):
self.config = config
self.logger = logger
self.store = LocalAccountingStore(config.storage_root)
self.tally = TallyLiveConnector()
def process(self, command: dict[str, Any]) -> dict[str, Any]:
command_id = str(command.get("command_id") or "").strip()
action = str(command.get("action") or "").strip()
payload = command.get("payload") or {}
result: dict[str, Any] | None = None
error: str | None = None
ok = False
try:
if action == "tally_status":
result = self._status(payload)
elif action == "phase1_status":
result = self._status(payload)
elif action == "accounting_initialize":
result = self._initialize(payload)
else:
raise ValueError(f"Unsupported local-agent command: {action}")
ok = True
except Exception as exc:
error = str(exc)
self.logger.exception("Agent command failed action=%s command_id=%s: %s", action, command_id, exc)
return {
"type": "command_result",
"command_id": command_id,
"ok": ok,
"result": result,
"error": error,
"agent_time_utc": datetime.now(timezone.utc).isoformat(),
}
def _status(self, payload: dict[str, Any]) -> dict[str, Any]:
tally_status = self.tally.status()
client_id = payload.get("client_id")
accounting = None
if client_id not in (None, ""):
client_id = int(client_id)
if self.store.exists(client_id):
self.store.record_tally_status(client_id, tally_status)
accounting = self.store.snapshot(client_id)
return {
"agent": {
"name": "ERP Local Agent",
"version": __version__,
"tally_capability": True,
"accounting_act_capability": True,
},
"tally": tally_status,
"accounting": accounting,
}
def _initialize(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id"))
client_name = str(payload.get("client_name") or "").strip()
path = self.store.initialize(client_id, client_name)
tally_status = self.tally.status()
self.store.record_tally_status(client_id, tally_status)
return {
"initialized": True,
"db_path": str(path),
"accounting": self.store.snapshot(client_id),
"tally": tally_status,
"agent": {"name": "ERP Local Agent", "version": __version__, "tally_capability": True, "accounting_act_capability": True},
}
@@ -49,7 +49,7 @@ class StorageAgent:
"free_bytes": free, "free_bytes": free,
"agent_version": __version__, "agent_version": __version__,
"agent_name": "ERP Local Agent", "agent_name": "ERP Local Agent",
"capabilities": ["storage"], "capabilities": ["storage", "tally", "accounting_act"],
"agent_time_utc": datetime.now(timezone.utc).isoformat(), "agent_time_utc": datetime.now(timezone.utc).isoformat(),
} }
self.client.heartbeat(payload) self.client.heartbeat(payload)
@@ -0,0 +1,143 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
import html
import re
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
DEFAULT_TALLY_HOST = "127.0.0.1"
DEFAULT_TALLY_PORT = 9000
DEFAULT_TALLY_TIMEOUT_SECONDS = 8
class TallyConnectionError(RuntimeError):
pass
def _clean_xml_response(xml_text: str) -> str:
if not xml_text:
return ""
xml_text = re.sub(r"&#(0?[0-8]|1[0-9]|2[0-9]|3[01]);", "", xml_text)
return re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F]", "", xml_text)
def _child_text(element: ET.Element, tag_name: str) -> str:
wanted = tag_name.upper()
for child in list(element):
if str(child.tag).split("}")[-1].upper() == wanted:
return (child.text or "").strip()
return ""
@dataclass(frozen=True)
class TallyCompany:
name: str
guid: str = ""
gstin: str = ""
def as_dict(self) -> dict[str, str]:
return asdict(self)
class TallyLiveConnector:
"""Read-only TallyPrime XML/HTTP discovery connector."""
def __init__(self, host: str = DEFAULT_TALLY_HOST, port: int = DEFAULT_TALLY_PORT, timeout: int = DEFAULT_TALLY_TIMEOUT_SECONDS):
self.host = (host or DEFAULT_TALLY_HOST).strip()
self.port = int(port or DEFAULT_TALLY_PORT)
self.timeout = max(1, int(timeout or DEFAULT_TALLY_TIMEOUT_SECONDS))
@property
def url(self) -> str:
return f"http://{self.host}:{self.port}"
def _post_xml(self, xml_text: str) -> str:
request = urllib.request.Request(
self.url,
data=xml_text.encode("utf-8"),
headers={"Content-Type": "application/xml; charset=utf-8"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
return response.read().decode("utf-8", errors="replace")
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise TallyConnectionError(
f"Could not connect to TallyPrime at {self.url}. Open TallyPrime and ensure its HTTP/XML server is available on port {self.port}. Details: {exc}"
) from exc
def get_loaded_companies(self) -> list[TallyCompany]:
xml = """<ENVELOPE>
<HEADER>
<VERSION>1</VERSION>
<TALLYREQUEST>Export</TALLYREQUEST>
<TYPE>Collection</TYPE>
<ID>ARRRAccountingLoadedCompanies</ID>
</HEADER>
<BODY>
<DESC>
<STATICVARIABLES><SVEXPORTFORMAT>$$SysName:XML</SVEXPORTFORMAT></STATICVARIABLES>
<TDL><TDLMESSAGE><COLLECTION NAME="ARRRAccountingLoadedCompanies" ISMODIFY="No"><TYPE>Company</TYPE><FETCH>Name,GUID,GSTRegistrationNumber</FETCH></COLLECTION></TDLMESSAGE></TDL>
</DESC>
</BODY>
</ENVELOPE>"""
return self._parse_companies(self._post_xml(xml))
def status(self) -> dict:
try:
companies = self.get_loaded_companies()
return {
"connected": True,
"url": self.url,
"host": self.host,
"port": self.port,
"company_count": len(companies),
"companies": [company.as_dict() for company in companies],
"error": None,
}
except TallyConnectionError as exc:
return {
"connected": False,
"url": self.url,
"host": self.host,
"port": self.port,
"company_count": 0,
"companies": [],
"error": str(exc),
}
@staticmethod
def _parse_companies(xml_text: str) -> list[TallyCompany]:
cleaned = _clean_xml_response(xml_text)
if not cleaned.strip():
return []
try:
root = ET.fromstring(cleaned.encode("utf-8"))
except Exception:
names: list[str] = []
for match in re.finditer(r"<NAME[^>]*>(.*?)</NAME>", cleaned, flags=re.I | re.S):
value = re.sub(r"<.*?>", "", match.group(1)).strip()
if value and value not in names:
names.append(value)
return [TallyCompany(name=name) for name in names]
companies: list[TallyCompany] = []
seen: set[str] = set()
for element in root.iter():
if str(element.tag).split("}")[-1].upper() != "COMPANY":
continue
name = (
element.attrib.get("NAME")
or element.attrib.get("name")
or _child_text(element, "NAME")
or _child_text(element, "BASICCOMPANYFORMALNAME")
).strip()
key = name.upper()
if not name or key in seen:
continue
seen.add(key)
companies.append(TallyCompany(name=name, guid=_child_text(element, "GUID"), gstin=_child_text(element, "GSTREGISTRATIONNUMBER")))
return companies
@@ -7,6 +7,7 @@ from typing import Any
import websockets import websockets
from .sync import StorageAgent from .sync import StorageAgent
from .commands import AgentCommandProcessor
class StorageAgentTunnel: class StorageAgentTunnel:
@@ -14,6 +15,7 @@ class StorageAgentTunnel:
self.agent = agent self.agent = agent
self.config = agent.config self.config = agent.config
self.logger = agent.logger self.logger = agent.logger
self.command_processor = AgentCommandProcessor(self.config, self.logger)
async def run_forever(self) -> None: async def run_forever(self) -> None:
self.logger.info("Starting tunnel mode for node=%s", self.config.node_code) self.logger.info("Starting tunnel mode for node=%s", self.config.node_code)
@@ -47,12 +49,18 @@ class StorageAgentTunnel:
if msg_type == "sync": if msg_type == "sync":
jobs = message.get("jobs") or [] jobs = message.get("jobs") or []
requests_ = message.get("download_requests") or message.get("requests") or [] requests_ = message.get("download_requests") or message.get("requests") or []
commands = message.get("commands") or []
await asyncio.to_thread(self.agent.process_storage_jobs_from_payload, jobs) await asyncio.to_thread(self.agent.process_storage_jobs_from_payload, jobs)
await asyncio.to_thread(self.agent.process_download_requests_from_payload, requests_) await asyncio.to_thread(self.agent.process_download_requests_from_payload, requests_)
for command in commands:
result = await asyncio.to_thread(self.command_processor.process, command)
await websocket.send(self._json_text(result))
await websocket.send(self._json_text({ await websocket.send(self._json_text({
"type": "agent_status", "type": "agent_status",
"jobs_seen": len(jobs), "jobs_seen": len(jobs),
"requests_seen": len(requests_), "requests_seen": len(requests_),
"commands_seen": len(commands),
"capabilities": ["storage", "tally", "accounting_act"],
"agent_time_utc": self._now(), "agent_time_utc": self._now(),
})) }))
continue continue
+7 -3
View File
@@ -1,4 +1,4 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
import asyncio import asyncio
@@ -82,6 +82,7 @@ from app.modules.core.tenancy.models import Branch, Tenant
from app.modules.core.tenancy.year_control import is_row_financial_year_locked from app.modules.core.tenancy.year_control import is_row_financial_year_locked
from app.modules.documents.agent_package import ERP_LOCAL_AGENT_VERSION, build_agent_env, build_preconfigured_agent_zip, build_agent_update_zip from app.modules.documents.agent_package import ERP_LOCAL_AGENT_VERSION, build_agent_env, build_preconfigured_agent_zip, build_agent_update_zip
from app.modules.documents.models import BranchStorageNode from app.modules.documents.models import BranchStorageNode
from app.modules.accounting.agent_bridge import list_pending_agent_commands, record_agent_command_result
from app.modules.services.task_documents import ( from app.modules.services.task_documents import (
get_task_with_subscription, get_task_with_subscription,
@@ -1693,7 +1694,8 @@ def _storage_agent_sync_payload(db, node):
jobs += _permanent_storage_jobs_payload(list_pending_permanent_storage_jobs(db, node)) jobs += _permanent_storage_jobs_payload(list_pending_permanent_storage_jobs(db, node))
requests_ = _normal_download_requests_payload(list_pending_download_requests(db, node)) requests_ = _normal_download_requests_payload(list_pending_download_requests(db, node))
requests_ += _permanent_download_requests_payload(list_pending_permanent_download_requests(db, node)) requests_ += _permanent_download_requests_payload(list_pending_permanent_download_requests(db, node))
return {"ok": True, "jobs": jobs, "download_requests": requests_, "requests": requests_} commands = list_pending_agent_commands(node.node_code, limit=10)
return {"ok": True, "jobs": jobs, "download_requests": requests_, "requests": requests_, "commands": commands}
@router.get("/storage-agent/jobs/pending") @router.get("/storage-agent/jobs/pending")
@@ -1837,7 +1839,9 @@ async def storage_agent_tunnel(websocket: WebSocket):
await websocket.send_json({"ok": False, "type": "error", "error": "node_deactivated_or_invalid"}) await websocket.send_json({"ok": False, "type": "error", "error": "node_deactivated_or_invalid"})
await websocket.close(code=1008) await websocket.close(code=1008)
return return
if message and message.get("type") in {"heartbeat", "agent_status"}: if message and message.get("type") == "command_result":
record_agent_command_result(node.node_code, message)
if message and message.get("type") in {"heartbeat", "agent_status", "command_result"}:
try: try:
node.storage_mode = "tunnel" node.storage_mode = "tunnel"
except Exception: except Exception:
+2
View File
@@ -36,6 +36,7 @@ from app.modules.firm_admin_dashboard.ui import router as firm_admin_dashboard_r
from app.modules.aqmm_dashboard.ui import router as aqmm_dashboard_router from app.modules.aqmm_dashboard.ui import router as aqmm_dashboard_router
from app.modules.peer_review_export.ui import router as peer_review_export_router from app.modules.peer_review_export.ui import router as peer_review_export_router
from app.modules.bank_statement_analyzer.ui import router as bank_statement_analyzer_router from app.modules.bank_statement_analyzer.ui import router as bank_statement_analyzer_router
from app.modules.accounting.ui import router as accounting_ui_router
from app.modules.registrations.ui import router as registrations_ui_router from app.modules.registrations.ui import router as registrations_ui_router
from app.modules.credential_vault.ui import router as credential_vault_ui_router from app.modules.credential_vault.ui import router as credential_vault_ui_router
from app.modules.client_identity.ui import router as client_identity_ui_router from app.modules.client_identity.ui import router as client_identity_ui_router
@@ -58,6 +59,7 @@ def mount_ui(app: FastAPI) -> None:
app.include_router(aqmm_dashboard_router) app.include_router(aqmm_dashboard_router)
app.include_router(peer_review_export_router) app.include_router(peer_review_export_router)
app.include_router(bank_statement_analyzer_router) app.include_router(bank_statement_analyzer_router)
app.include_router(accounting_ui_router)
app.include_router(work_tracker_ui_router) app.include_router(work_tracker_ui_router)
app.include_router(billing_ui_router) app.include_router(billing_ui_router)
app.include_router(platform_billing_ui_router) app.include_router(platform_billing_ui_router)
@@ -1,4 +1,4 @@
{% if navigation_render_location_v2|default('page') == 'global-host' or not global_role_navigation_host_v2|default(false) %} {% if navigation_render_location_v2|default('page') == 'global-host' or not global_role_navigation_host_v2|default(false) %}
{# {#
Partner operations navigation. Partner operations navigation.
Presentation only: all links use existing production routes and route-level permissions remain authoritative. Presentation only: all links use existing production routes and route-level permissions remain authoritative.
@@ -212,6 +212,11 @@
'visible': true, 'visible': true,
'active': (_partner_path.startswith('/partner/dashboard') and _partner_tab == 'wizards') or _partner_path.startswith('/tools/bank-statement-analyzer') or _partner_path.startswith('/documents/storage-nodes') or _partner_path.startswith('/documents/storage-jobs'), 'active': (_partner_path.startswith('/partner/dashboard') and _partner_tab == 'wizards') or _partner_path.startswith('/tools/bank-statement-analyzer') or _partner_path.startswith('/documents/storage-nodes') or _partner_path.startswith('/documents/storage-jobs'),
'children': [ 'children': [
{
'label': 'Tally',
'url': '/tools/tally',
'active': _partner_path.startswith('/tools/tally')
},
{ {
'label': 'Bank Analyzer', 'label': 'Bank Analyzer',
'url': '/tools/bank-statement-analyzer', 'url': '/tools/bank-statement-analyzer',
@@ -297,3 +302,4 @@
})(); })();
</script> </script>
{% endif %} {% endif %}