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
+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()