Fix cash ledger SQLite result transport to VPS

This commit is contained in:
A R R R Associates
2026-09-04 23:27:29 +05:30
parent 8536dc0457
commit 9cceec0283
5 changed files with 634 additions and 268 deletions
+122 -29
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from urllib.parse import quote
import uuid
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse, JSONResponse
@@ -252,10 +253,11 @@ def cash_payment_review(
@router.post("/ledgers")
async def cash_payment_ledgers(request: Request):
"""Load Cash ledgers from the common client .act SQLite master snapshot.
"""Queue Cash-ledger preparation and return a durable VPS-known job id.
Normal loads are local-only. A live Tally master refresh happens only when
the user explicitly clicks Refresh Master Data or when no local snapshot exists.
The Local Agent reads the common client .act master in the background. Normal
loads are SQLite-only. Refresh Master Data is the only path that is allowed to
contact TallyPrime for a master refresh.
"""
form = await request.form()
validate_csrf(request, str(form.get("csrf_token") or ""))
@@ -284,37 +286,72 @@ async def cash_payment_ledgers(request: Request):
if not node or not _node_online(node):
return JSONResponse({"ok": False, "error": "ERP Local Agent is offline."}, status_code=409)
# The VPS generates the job id before sending the command. Even when a
# WebSocket ACK is lost, the browser can poll this known id and retrieve the
# ledger payload once the Local Agent has completed the SQLite read.
job_id = "CASHMASTER-" + uuid.uuid4().hex[:16].upper()
try:
fy = _financial_year_for_date(date.fromisoformat(date_from))
payload = {
**_accounting_storage_payload(selected_client, fy),
"tally_guid": tally_guid,
"company_name": company_name,
"ledger_scope": ledger_scope,
"force_refresh": force_refresh,
"job_id": job_id,
"requested_by_user_id": int(user.id),
}
# Reuse the common accounting master tables already stored in the
# client .act SQLite file. This avoids repeatedly querying TallyPrime
# just to populate a ledger selector. The Local Agent performs a live
# master refresh only when force_refresh is explicitly requested, or
# when no usable local snapshot exists yet.
payload["ledger_scope"] = ledger_scope
payload["force_refresh"] = force_refresh
result = request_agent_command(
node.node_code,
"accounting_cash_payment_cash_ledgers",
payload,
timeout_seconds=180 if force_refresh else 45,
timeout_seconds=12,
)
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
if not result.get("ok"):
return JSONResponse({"ok": False, "error": str(result.get("error") or "Could not load Tally ledgers.")}, status_code=409)
return JSONResponse({"ok": True, **(result.get("result") or {})})
if result.get("ok"):
body = result.get("result") or {}
job = body.get("job") or {
"job_id": job_id,
"status": "queued",
"stage": "Local Agent accepted Cash ledger job",
"percent": 5,
}
return JSONResponse({"ok": True, "accepted": True, "job": job, **body})
return JSONResponse({
"ok": False,
"error": str(result.get("error") or "Local Agent rejected the Cash ledger request."),
}, status_code=409)
except Exception:
# A transport timeout does not mean the Local Agent failed. The job id
# is durable and known to the VPS; return it immediately and let the
# browser poll. This is specifically designed for intermittent tunnel
# keepalive/ACK loss.
return JSONResponse({
"ok": True,
"accepted": True,
"transport_pending": True,
"job": {
"job_id": job_id,
"status": "queued",
"stage": "Command sent; waiting for Local Agent acknowledgment",
"percent": 5,
"company_name": company_name,
},
})
finally:
db.close()
@router.get("/ledgers/progress")
def cash_payment_ledgers_progress(request: Request, client_id: int, job_id: str, date_from: str):
def cash_payment_ledgers_progress(
request: Request,
client_id: int,
job_id: str,
date_from: str,
tally_guid: str = "",
company_name: str = "",
ledger_scope: str = "cash",
force_refresh: int = 0,
):
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.view")
@@ -326,23 +363,79 @@ def cash_payment_ledgers_progress(request: Request, client_id: int, job_id: str,
return JSONResponse({"ok": False, "error": "Client not found."}, status_code=404)
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
if not node or not _node_online(node):
return JSONResponse({"ok": False, "error": "ERP Local Agent is offline."}, status_code=409)
return JSONResponse({
"ok": True,
"transport_pending": True,
"job": {
"job_id": job_id,
"status": "running",
"stage": "Waiting for ERP Local Agent tunnel",
"percent": 85,
"company_name": company_name,
},
})
fy = _financial_year_for_date(date.fromisoformat(date_from))
payload = {
**_accounting_storage_payload(selected_client, fy),
"job_id": str(job_id),
"tally_guid": str(tally_guid or ""),
"company_name": str(company_name or ""),
"ledger_scope": str(ledger_scope or "cash"),
"force_refresh": bool(force_refresh),
"requested_by_user_id": int(user.id),
}
try:
fy = _financial_year_for_date(date.fromisoformat(date_from))
result = request_agent_command(
node.node_code,
"accounting_cash_payment_ledgers_status",
{
**_accounting_storage_payload(selected_client, fy),
"job_id": str(job_id),
},
timeout_seconds=15,
payload,
timeout_seconds=10,
)
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
if not result.get("ok"):
return JSONResponse({"ok": False, "error": str(result.get("error") or "Could not read ledger-sync progress.")}, status_code=409)
return JSONResponse({"ok": True, **(result.get("result") or {})})
except Exception:
return JSONResponse({
"ok": True,
"transport_pending": True,
"job": {
"job_id": job_id,
"status": "running",
"stage": "Local work complete or running; waiting to transfer result to VPS",
"percent": 90,
"company_name": company_name,
},
})
if result.get("ok"):
return JSONResponse({"ok": True, **(result.get("result") or {})})
error = str(result.get("error") or "")
# If the initial start command itself was lost before reaching the Local
# Agent, resend the same deterministic job id. The Local Agent treats this
# idempotently, so this cannot create duplicate work.
if "not found" in error.casefold():
try:
restarted = request_agent_command(
node.node_code,
"accounting_cash_payment_cash_ledgers",
payload,
timeout_seconds=10,
)
if restarted.get("ok"):
return JSONResponse({"ok": True, **(restarted.get("result") or {})})
except Exception:
pass
return JSONResponse({
"ok": True,
"transport_pending": True,
"job": {
"job_id": job_id,
"status": "queued",
"stage": "Retrying Cash ledger job delivery to Local Agent",
"percent": 10,
"company_name": company_name,
},
})
return JSONResponse({"ok": False, "error": error or "Could not read Cash ledger progress."}, status_code=409)
finally:
db.close()