Add SQLite cached slow Tally extraction for cash payment review
This commit is contained in:
@@ -5,7 +5,7 @@ from decimal import Decimal, InvalidOperation
|
|||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse, JSONResponse
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.core.db.common import CommonSessionLocal
|
from app.core.db.common import CommonSessionLocal
|
||||||
@@ -146,7 +146,8 @@ def cash_payment_review(
|
|||||||
tally_guid: str = "",
|
tally_guid: str = "",
|
||||||
date_from: str = "",
|
date_from: str = "",
|
||||||
date_to: str = "",
|
date_to: str = "",
|
||||||
run: int = 0,
|
job_id: str = "",
|
||||||
|
analyze: int = 0,
|
||||||
saved: int = 0,
|
saved: int = 0,
|
||||||
error: str = "",
|
error: str = "",
|
||||||
):
|
):
|
||||||
@@ -169,6 +170,7 @@ def cash_payment_review(
|
|||||||
end_text = date_to or today.isoformat()
|
end_text = date_to or today.isoformat()
|
||||||
live_result = None
|
live_result = None
|
||||||
review = None
|
review = None
|
||||||
|
cache_job = None
|
||||||
command_error = error or ""
|
command_error = error or ""
|
||||||
|
|
||||||
if selected_client and node and online:
|
if selected_client and node and online:
|
||||||
@@ -183,28 +185,39 @@ def cash_payment_review(
|
|||||||
live_result = status_response.get("result") or {}
|
live_result = status_response.get("result") or {}
|
||||||
else:
|
else:
|
||||||
command_error = str(status_response.get("error") or "Local Agent status failed.")
|
command_error = str(status_response.get("error") or "Local Agent status failed.")
|
||||||
if run and tally_guid:
|
|
||||||
result = request_agent_command(
|
if job_id:
|
||||||
|
status = request_agent_command(
|
||||||
node.node_code,
|
node.node_code,
|
||||||
"accounting_cash_payment_compliance",
|
"accounting_cash_payment_cache_status",
|
||||||
{
|
{
|
||||||
**_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))),
|
**_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))),
|
||||||
"tally_guid": str(tally_guid),
|
"job_id": str(job_id),
|
||||||
"date_from": start_text,
|
},
|
||||||
"date_to": end_text,
|
timeout_seconds=20,
|
||||||
|
)
|
||||||
|
if status.get("ok"):
|
||||||
|
cache_job = (status.get("result") or {}).get("job")
|
||||||
|
else:
|
||||||
|
command_error = str(status.get("error") or "Could not read cash-payment cache status.")
|
||||||
|
|
||||||
|
if analyze and job_id and cache_job and cache_job.get("status") == "completed":
|
||||||
|
result = request_agent_command(
|
||||||
|
node.node_code,
|
||||||
|
"accounting_cash_payment_cache_analyze",
|
||||||
|
{
|
||||||
|
**_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))),
|
||||||
|
"job_id": str(job_id),
|
||||||
"cash_limit": float(setting.cash_limit),
|
"cash_limit": float(setting.cash_limit),
|
||||||
"split_window_days": int(setting.split_window_days),
|
"split_window_days": int(setting.split_window_days),
|
||||||
"near_limit_percent": float(setting.near_limit_percent),
|
"near_limit_percent": float(setting.near_limit_percent),
|
||||||
"tally_batch_days": 7,
|
|
||||||
"tally_pause_seconds": 1.5,
|
|
||||||
"tally_retry_pause_seconds": 3.0,
|
|
||||||
},
|
},
|
||||||
timeout_seconds=900,
|
timeout_seconds=120,
|
||||||
)
|
)
|
||||||
if result.get("ok"):
|
if result.get("ok"):
|
||||||
review = (result.get("result") or {}).get("cash_payment_review")
|
review = (result.get("result") or {}).get("cash_payment_review")
|
||||||
else:
|
else:
|
||||||
command_error = str(result.get("error") or "Cash payment review failed.")
|
command_error = str(result.get("error") or "Cash payment SQLite analysis failed.")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
command_error = str(exc)
|
command_error = str(exc)
|
||||||
|
|
||||||
@@ -223,6 +236,8 @@ def cash_payment_review(
|
|||||||
date_to=end_text,
|
date_to=end_text,
|
||||||
setting=setting,
|
setting=setting,
|
||||||
review=review,
|
review=review,
|
||||||
|
cache_job=cache_job,
|
||||||
|
active_job_id=str(job_id or ""),
|
||||||
saved=bool(saved),
|
saved=bool(saved),
|
||||||
command_error=command_error,
|
command_error=command_error,
|
||||||
allocation_plan=None,
|
allocation_plan=None,
|
||||||
@@ -233,6 +248,96 @@ def cash_payment_review(
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cache/start")
|
||||||
|
async def start_cash_payment_cache(request: Request):
|
||||||
|
form = await request.form()
|
||||||
|
validate_csrf(request, str(form.get("csrf_token") or ""))
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user, response = _require_partner(request, db, "accounting.tally.view")
|
||||||
|
if response:
|
||||||
|
return JSONResponse({"ok": False, "error": "Access denied."}, status_code=403)
|
||||||
|
clients, scope = _visible_clients(db, request, user)
|
||||||
|
try:
|
||||||
|
client_id = int(form.get("client_id") or 0)
|
||||||
|
except Exception:
|
||||||
|
client_id = 0
|
||||||
|
selected_client = next((row for row in clients if int(row.id) == client_id), None)
|
||||||
|
if not selected_client:
|
||||||
|
return JSONResponse({"ok": False, "error": "Select a valid client."}, status_code=400)
|
||||||
|
tally_guid = str(form.get("tally_guid") or "").strip()
|
||||||
|
date_from = str(form.get("date_from") or "").strip()
|
||||||
|
date_to = str(form.get("date_to") or "").strip()
|
||||||
|
try:
|
||||||
|
parsed_from = date.fromisoformat(date_from)
|
||||||
|
parsed_to = date.fromisoformat(date_to)
|
||||||
|
if parsed_to < parsed_from:
|
||||||
|
raise ValueError("To date cannot be before From date.")
|
||||||
|
except Exception as exc:
|
||||||
|
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
|
||||||
|
if not tally_guid:
|
||||||
|
return JSONResponse({"ok": False, "error": "Select a mapped Tally company."}, status_code=400)
|
||||||
|
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)
|
||||||
|
setting = _setting(db, _tenant_id(request, user))
|
||||||
|
try:
|
||||||
|
result = request_agent_command(
|
||||||
|
node.node_code,
|
||||||
|
"accounting_cash_payment_cache_start",
|
||||||
|
{
|
||||||
|
**_accounting_storage_payload(selected_client, _financial_year_for_date(parsed_from)),
|
||||||
|
"tally_guid": tally_guid,
|
||||||
|
"date_from": date_from,
|
||||||
|
"date_to": date_to,
|
||||||
|
"cash_limit": float(setting.cash_limit),
|
||||||
|
"split_window_days": int(setting.split_window_days),
|
||||||
|
"near_limit_percent": float(setting.near_limit_percent),
|
||||||
|
"tally_pause_seconds": 3.0,
|
||||||
|
"requested_by_user_id": int(user.id),
|
||||||
|
},
|
||||||
|
timeout_seconds=30,
|
||||||
|
)
|
||||||
|
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 start Tally extraction.")}, status_code=409)
|
||||||
|
return JSONResponse({"ok": True, **(result.get("result") or {})})
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cache/progress")
|
||||||
|
def cash_payment_cache_progress(request: Request, client_id: int, job_id: str, date_from: str):
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user, response = _require_partner(request, db, "accounting.tally.view")
|
||||||
|
if response:
|
||||||
|
return JSONResponse({"ok": False, "error": "Access denied."}, status_code=403)
|
||||||
|
clients, scope = _visible_clients(db, request, user)
|
||||||
|
selected_client = next((row for row in clients if int(row.id) == int(client_id)), None)
|
||||||
|
if not selected_client:
|
||||||
|
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)
|
||||||
|
try:
|
||||||
|
fy = _financial_year_for_date(date.fromisoformat(date_from))
|
||||||
|
result = request_agent_command(
|
||||||
|
node.node_code,
|
||||||
|
"accounting_cash_payment_cache_status",
|
||||||
|
{**_accounting_storage_payload(selected_client, fy), "job_id": str(job_id)},
|
||||||
|
timeout_seconds=20,
|
||||||
|
)
|
||||||
|
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 extraction progress.")}, status_code=409)
|
||||||
|
return JSONResponse({"ok": True, **(result.get("result") or {})})
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/allocation")
|
@router.get("/allocation")
|
||||||
def cash_payment_allocation(
|
def cash_payment_allocation(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -263,6 +368,8 @@ def cash_payment_allocation(
|
|||||||
date_to="",
|
date_to="",
|
||||||
setting=setting,
|
setting=setting,
|
||||||
review=None,
|
review=None,
|
||||||
|
cache_job=None,
|
||||||
|
active_job_id="",
|
||||||
saved=False,
|
saved=False,
|
||||||
command_error=error or "",
|
command_error=error or "",
|
||||||
allocation_plan=None,
|
allocation_plan=None,
|
||||||
@@ -383,6 +490,8 @@ async def cash_payment_allocation_plan(request: Request):
|
|||||||
date_to="",
|
date_to="",
|
||||||
setting=setting,
|
setting=setting,
|
||||||
review=None,
|
review=None,
|
||||||
|
cache_job=None,
|
||||||
|
active_job_id="",
|
||||||
saved=False,
|
saved=False,
|
||||||
command_error="",
|
command_error="",
|
||||||
allocation_plan=plan,
|
allocation_plan=plan,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<div class="rounded-2xl bg-white p-5 shadow-soft lg:col-span-2">
|
<div class="rounded-2xl bg-white p-5 shadow-soft lg:col-span-2">
|
||||||
<h2 class="text-lg font-semibold text-slate-900">Cash Payment Compliance Review</h2>
|
<h2 class="text-lg font-semibold text-slate-900">Cash Payment Compliance Review</h2>
|
||||||
<form id="cash-review-form" method="get" action="/tools/accounting/cash-payments" class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<form id="cash-review-form" method="get" action="/tools/accounting/cash-payments" class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<input type="hidden" id="cash-csrf-token" value="{{ csrf_token }}">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs font-semibold uppercase text-slate-500">Client</label>
|
<label class="text-xs font-semibold uppercase text-slate-500">Client</label>
|
||||||
<select name="client_id" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required onchange="this.form.submit()">
|
<select name="client_id" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required onchange="this.form.submit()">
|
||||||
@@ -42,8 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div><label class="text-xs font-semibold uppercase text-slate-500">From</label><input type="date" name="date_from" value="{{ date_from }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
|
<div><label class="text-xs font-semibold uppercase text-slate-500">From</label><input type="date" name="date_from" value="{{ date_from }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
|
||||||
<div><label class="text-xs font-semibold uppercase text-slate-500">To</label><input type="date" name="date_to" value="{{ date_to }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
|
<div><label class="text-xs font-semibold uppercase text-slate-500">To</label><input type="date" name="date_to" value="{{ date_to }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
|
||||||
<input type="hidden" name="run" value="1">
|
<div class="md:col-span-2 xl:col-span-4"><button type="button" id="cash-review-button" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white" {% if not selected_client %}disabled{% endif %}>Extract / Refresh & Analyze</button><p class="mt-2 text-xs text-slate-500">Transactions are copied very slowly into the Local Agent SQLite accounting cache one day at a time. Analysis starts only after TallyPrime has been released.</p></div>
|
||||||
<div class="md:col-span-2 xl:col-span-4"><button id="cash-review-button" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white" {% if not selected_client %}disabled{% endif %}>Run Tally Review</button><p class="mt-2 text-xs text-slate-500">Safe-paced mode reads Tally in monthly batches with a short pause between requests to reduce load on TallyPrime.</p></div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,10 +61,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% if cache_job %}
|
||||||
|
<section class="rounded-2xl border border-sky-200 bg-sky-50 p-5">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-sky-950">Local Tally SQLite Cache</h3>
|
||||||
|
<p class="mt-1 text-sm text-sky-800">Status: <strong>{{ cache_job.status }}</strong>{% if cache_job.stage %} · {{ cache_job.stage }}{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right text-sm text-sky-900"><div>{{ cache_job.completed_days }}/{{ cache_job.total_days }} day(s)</div><div>{{ cache_job.vouchers_cached }} voucher(s) cached</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 h-2 overflow-hidden rounded-full bg-sky-100"><div class="h-full bg-sky-600" style="width:{{ cache_job.percent or 0 }}%"></div></div>
|
||||||
|
{% if cache_job.error %}<p class="mt-3 text-sm font-medium text-amber-900">{{ cache_job.error }}</p>{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if review %}
|
{% if review %}
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
{% if review.safe_paced_mode %}
|
{% if review.sqlite_cache %}
|
||||||
<div class="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800">Completed in adaptive safe-paced mode using {{ review.tally_request_batches or 0 }} successful voucher batch(es) across {{ review.tally_request_count or 0 }} Tally request(s), with {{ review.tally_retry_count or 0 }} retry/retries and {{ review.tally_fallback_batches or 0 }} adaptive fallback(s). The agent pauses approximately {{ review.tally_pause_ms or 0 }} ms between Tally calls.</div>
|
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Analysis completed from the Local Agent SQLite cache. TallyPrime was used only for the slow background extraction and was released before this analysis ran. {{ review.tally_request_count or 0 }} Tally request(s), {{ review.tally_retry_count or 0 }} retry/retries, and approximately {{ review.tally_pause_ms or 0 }} ms cooling between successful day reads.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||||
{% for label, value in [('Vouchers reviewed', review.summary.vouchers_reviewed), ('Cash payments', review.summary.cash_payment_vouchers), ('Single-voucher exceptions', review.summary.single_voucher_exceptions), ('Same-day exceptions', review.summary.same_day_exceptions), ('Possible split patterns', review.summary.possible_split_patterns)] %}
|
{% for label, value in [('Vouchers reviewed', review.summary.vouchers_reviewed), ('Cash payments', review.summary.cash_payment_vouchers), ('Single-voucher exceptions', review.summary.single_voucher_exceptions), ('Same-day exceptions', review.summary.same_day_exceptions), ('Possible split patterns', review.summary.possible_split_patterns)] %}
|
||||||
@@ -119,60 +133,126 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div id="cash-review-progress" class="fixed inset-0 z-[100] hidden items-center justify-center bg-slate-950/40 p-4 backdrop-blur-sm">
|
<div id="cash-review-progress" class="fixed inset-0 z-[100] hidden items-center justify-center bg-slate-950/40 p-4 backdrop-blur-sm">
|
||||||
<div class="w-full max-w-xl rounded-2xl bg-white p-6 shadow-2xl">
|
<div class="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-2xl">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-semibold text-slate-900">Cash Payment Analysis in Progress</h3>
|
<h3 class="text-lg font-semibold text-slate-900">Slow Tally Extraction to Local SQLite</h3>
|
||||||
<p id="cash-progress-text" class="mt-1 text-sm text-slate-600">Sending the review request to the ERP Local Agent…</p>
|
<p id="cash-progress-text" class="mt-1 text-sm text-slate-600">Submitting a background extraction job to the ERP Local Agent…</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="cash-progress-percent" class="text-lg font-semibold text-brand-700">5%</div>
|
<div id="cash-progress-percent" class="text-lg font-semibold text-brand-700">0%</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 h-3 overflow-hidden rounded-full bg-slate-200">
|
<div class="mt-5 h-3 overflow-hidden rounded-full bg-slate-200">
|
||||||
<div id="cash-progress-bar" class="h-full rounded-full bg-brand-600 transition-all duration-700" style="width:5%"></div>
|
<div id="cash-progress-bar" class="h-full rounded-full bg-brand-600 transition-all duration-500" style="width:0%"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 grid grid-cols-4 gap-2 text-center text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
<div class="mt-4 grid grid-cols-2 gap-3 text-sm md:grid-cols-4">
|
||||||
<span>Agent</span><span>Tally</span><span>Vouchers</span><span>Analysis</span>
|
<div class="rounded-xl bg-slate-50 p-3"><div class="text-xs uppercase text-slate-500">Days</div><div id="cash-progress-days" class="mt-1 font-semibold">0 / 0</div></div>
|
||||||
|
<div class="rounded-xl bg-slate-50 p-3"><div class="text-xs uppercase text-slate-500">Vouchers cached</div><div id="cash-progress-vouchers" class="mt-1 font-semibold">0</div></div>
|
||||||
|
<div class="rounded-xl bg-slate-50 p-3"><div class="text-xs uppercase text-slate-500">Tally requests</div><div id="cash-progress-requests" class="mt-1 font-semibold">0</div></div>
|
||||||
|
<div class="rounded-xl bg-slate-50 p-3"><div class="text-xs uppercase text-slate-500">Current date</div><div id="cash-progress-date" class="mt-1 font-semibold">—</div></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-4 text-xs text-slate-500">Tally is deliberately read in 7-day batches with pauses between requests. If a batch times out, the agent automatically retries and reduces it to 3-day and then 1-day batches. Please keep TallyPrime and the ERP Local Agent open until this finishes.</p>
|
<p class="mt-4 text-xs text-slate-500">Only one day is requested from TallyPrime at a time. The agent waits about 3 seconds after every successful day. If Tally responds slowly, it cools for 20 seconds and retries only once. The tunnel heartbeat stays responsive because progress is read from SQLite, not from Tally.</p>
|
||||||
|
<div id="cash-progress-error" class="mt-4 hidden rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900"></div>
|
||||||
|
<div class="mt-4 flex justify-end"><button type="button" id="cash-progress-close" class="hidden rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Close</button></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const form = document.getElementById('cash-review-form');
|
const form = document.getElementById('cash-review-form');
|
||||||
if (!form) return;
|
const button = document.getElementById('cash-review-button');
|
||||||
|
if (!form || !button) return;
|
||||||
const overlay = document.getElementById('cash-review-progress');
|
const overlay = document.getElementById('cash-review-progress');
|
||||||
const bar = document.getElementById('cash-progress-bar');
|
const bar = document.getElementById('cash-progress-bar');
|
||||||
const pct = document.getElementById('cash-progress-percent');
|
const pct = document.getElementById('cash-progress-percent');
|
||||||
const text = document.getElementById('cash-progress-text');
|
const text = document.getElementById('cash-progress-text');
|
||||||
const button = document.getElementById('cash-review-button');
|
const days = document.getElementById('cash-progress-days');
|
||||||
const stages = [
|
const vouchers = document.getElementById('cash-progress-vouchers');
|
||||||
[8, 'Sending the review request to the ERP Local Agent…'],
|
const requests = document.getElementById('cash-progress-requests');
|
||||||
[18, 'Connecting to TallyPrime on the workstation…'],
|
const currentDate = document.getElementById('cash-progress-date');
|
||||||
[32, 'Reading cash and ledger masters…'],
|
const errorBox = document.getElementById('cash-progress-error');
|
||||||
[42, 'Reading vouchers in safe 7-day batches…'],
|
const closeButton = document.getElementById('cash-progress-close');
|
||||||
[58, 'Continuing voucher extraction with 1.5 second pauses…'],
|
let pollTimer = null;
|
||||||
[70, 'Large batches automatically fall back to 3-day or 1-day reads if needed…'],
|
|
||||||
[78, 'Checking same-day cash payment aggregation…'],
|
function show() {
|
||||||
[88, 'Checking possible split-payment patterns…'],
|
|
||||||
[94, 'Preparing the compliance report…']
|
|
||||||
];
|
|
||||||
form.addEventListener('submit', function (event) {
|
|
||||||
const submitter = event.submitter;
|
|
||||||
// Client dropdown auto-submit should remain quick and should not show a review progress dialog.
|
|
||||||
if (!submitter || submitter.id !== 'cash-review-button') return;
|
|
||||||
overlay.classList.remove('hidden');
|
overlay.classList.remove('hidden');
|
||||||
overlay.classList.add('flex');
|
overlay.classList.add('flex');
|
||||||
|
errorBox.classList.add('hidden');
|
||||||
|
closeButton.classList.add('hidden');
|
||||||
|
}
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer) window.clearTimeout(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
function fail(message) {
|
||||||
|
stopPolling();
|
||||||
|
button.disabled = false;
|
||||||
|
text.textContent = 'Extraction paused safely.';
|
||||||
|
errorBox.textContent = message || 'The extraction did not complete.';
|
||||||
|
errorBox.classList.remove('hidden');
|
||||||
|
closeButton.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function update(job) {
|
||||||
|
const value = Math.max(0, Math.min(100, Number(job.percent || 0)));
|
||||||
|
bar.style.width = value + '%';
|
||||||
|
pct.textContent = value + '%';
|
||||||
|
text.textContent = job.stage || job.status || 'Working…';
|
||||||
|
days.textContent = String(job.completed_days || 0) + ' / ' + String(job.total_days || 0);
|
||||||
|
vouchers.textContent = String(job.vouchers_cached || 0);
|
||||||
|
requests.textContent = String(job.tally_requests || 0);
|
||||||
|
currentDate.textContent = job.current_date || '—';
|
||||||
|
}
|
||||||
|
async function poll(jobId, clientId, dateFrom, tallyGuid, dateTo) {
|
||||||
|
try {
|
||||||
|
const url = new URL('/tools/accounting/cash-payments/cache/progress', window.location.origin);
|
||||||
|
url.searchParams.set('client_id', clientId);
|
||||||
|
url.searchParams.set('job_id', jobId);
|
||||||
|
url.searchParams.set('date_from', dateFrom);
|
||||||
|
const response = await fetch(url.toString(), {headers: {'Accept': 'application/json'}});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || !data.ok) throw new Error(data.error || 'Could not read extraction progress.');
|
||||||
|
const job = data.job || {};
|
||||||
|
update(job);
|
||||||
|
if (job.status === 'completed') {
|
||||||
|
text.textContent = 'SQLite extraction complete. Running cash-payment analysis locally…';
|
||||||
|
bar.style.width = '100%'; pct.textContent = '100%';
|
||||||
|
const target = new URL('/tools/accounting/cash-payments', window.location.origin);
|
||||||
|
target.searchParams.set('client_id', clientId);
|
||||||
|
target.searchParams.set('tally_guid', tallyGuid);
|
||||||
|
target.searchParams.set('date_from', dateFrom);
|
||||||
|
target.searchParams.set('date_to', dateTo);
|
||||||
|
target.searchParams.set('job_id', jobId);
|
||||||
|
target.searchParams.set('analyze', '1');
|
||||||
|
window.location.assign(target.toString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (job.status === 'paused' || job.status === 'failed') {
|
||||||
|
fail(job.error || 'Tally extraction paused. Restart/verify TallyPrime and click Extract / Refresh & Analyze again to resume.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pollTimer = window.setTimeout(() => poll(jobId, clientId, dateFrom, tallyGuid, dateTo), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
fail(err && err.message ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button.addEventListener('click', async function () {
|
||||||
|
if (!form.reportValidity()) return;
|
||||||
|
const fd = new FormData(form);
|
||||||
|
fd.set('csrf_token', document.getElementById('cash-csrf-token').value);
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
let i = 0;
|
show();
|
||||||
const advance = function () {
|
try {
|
||||||
if (i >= stages.length) return;
|
const response = await fetch('/tools/accounting/cash-payments/cache/start', {method: 'POST', body: fd, headers: {'Accept': 'application/json'}});
|
||||||
const stage = stages[i++];
|
const data = await response.json();
|
||||||
bar.style.width = stage[0] + '%';
|
if (!response.ok || !data.ok) throw new Error(data.error || 'Could not start extraction.');
|
||||||
pct.textContent = stage[0] + '%';
|
const job = data.job || {};
|
||||||
text.textContent = stage[1];
|
update(job);
|
||||||
if (i < stages.length) window.setTimeout(advance, i < 4 ? 1800 : 4500);
|
poll(job.job_id, fd.get('client_id'), fd.get('date_from'), fd.get('tally_guid'), fd.get('date_to'));
|
||||||
};
|
} catch (err) {
|
||||||
advance();
|
fail(err && err.message ? err.message : String(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
closeButton.addEventListener('click', function () {
|
||||||
|
overlay.classList.add('hidden'); overlay.classList.remove('flex');
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import io
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
ERP_LOCAL_AGENT_VERSION = "1.22.7"
|
ERP_LOCAL_AGENT_VERSION = "1.22.8"
|
||||||
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"
|
||||||
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
__version__ = "1.22.7"
|
__version__ = "1.22.8"
|
||||||
AGENT_NAME = "ERP Local Agent"
|
AGENT_NAME = "ERP Local Agent"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from datetime import datetime, timezone, date as _dt_date, timedelta as _timedel
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
@@ -13,6 +15,12 @@ from .native_voucher_engine import NativeVoucherEngine
|
|||||||
|
|
||||||
|
|
||||||
_CASH_TALLY_EXTRACTION_LOCK = threading.Lock()
|
_CASH_TALLY_EXTRACTION_LOCK = threading.Lock()
|
||||||
|
_CASH_CACHE_BUSY = threading.Event()
|
||||||
|
_CASH_CACHE_THREADS: dict[str, threading.Thread] = {}
|
||||||
|
_CASH_CACHE_THREADS_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
def is_tally_cache_busy() -> bool:
|
||||||
|
return _CASH_CACHE_BUSY.is_set()
|
||||||
|
|
||||||
|
|
||||||
class AgentCommandProcessor:
|
class AgentCommandProcessor:
|
||||||
@@ -61,6 +69,12 @@ class AgentCommandProcessor:
|
|||||||
result = self._bank_posting_preflight(payload)
|
result = self._bank_posting_preflight(payload)
|
||||||
elif action == "accounting_bank_reconciliation_extract":
|
elif action == "accounting_bank_reconciliation_extract":
|
||||||
result = self._bank_reconciliation_extract(payload)
|
result = self._bank_reconciliation_extract(payload)
|
||||||
|
elif action == "accounting_cash_payment_cache_start":
|
||||||
|
result = self._cash_payment_cache_start(payload)
|
||||||
|
elif action == "accounting_cash_payment_cache_status":
|
||||||
|
result = self._cash_payment_cache_status(payload)
|
||||||
|
elif action == "accounting_cash_payment_cache_analyze":
|
||||||
|
result = self._cash_payment_cache_analyze(payload)
|
||||||
elif action == "accounting_cash_payment_compliance":
|
elif action == "accounting_cash_payment_compliance":
|
||||||
result = self._cash_payment_compliance(payload)
|
result = self._cash_payment_compliance(payload)
|
||||||
elif action == "accounting_tds_compliance":
|
elif action == "accounting_tds_compliance":
|
||||||
@@ -109,6 +123,8 @@ class AgentCommandProcessor:
|
|||||||
"purchase_voucher_write_capability": True,
|
"purchase_voucher_write_capability": True,
|
||||||
"it_depreciation_capability": True,
|
"it_depreciation_capability": True,
|
||||||
"cash_payment_compliance_capability": True,
|
"cash_payment_compliance_capability": True,
|
||||||
|
"cash_payment_sqlite_cache_capability": True,
|
||||||
|
"cash_payment_background_sync_capability": True,
|
||||||
"tally_writeback_capability": True,
|
"tally_writeback_capability": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,6 +665,424 @@ class AgentCommandProcessor:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_cash_cache_schema(self, client_id: int) -> None:
|
||||||
|
with self.store.connect(client_id) as db:
|
||||||
|
db.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS cash_payment_cache_jobs (
|
||||||
|
job_id TEXT PRIMARY KEY,
|
||||||
|
client_id INTEGER NOT NULL,
|
||||||
|
tally_guid TEXT NOT NULL,
|
||||||
|
company_name TEXT NOT NULL,
|
||||||
|
date_from TEXT NOT NULL,
|
||||||
|
date_to TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
stage TEXT NOT NULL DEFAULT '',
|
||||||
|
current_date TEXT NOT NULL DEFAULT '',
|
||||||
|
total_days INTEGER NOT NULL DEFAULT 0,
|
||||||
|
completed_days INTEGER NOT NULL DEFAULT 0,
|
||||||
|
vouchers_cached INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tally_requests INTEGER NOT NULL DEFAULT 0,
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pause_seconds REAL NOT NULL DEFAULT 3,
|
||||||
|
cash_ledgers_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
settings_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
started_at_utc TEXT NOT NULL,
|
||||||
|
updated_at_utc TEXT NOT NULL,
|
||||||
|
completed_at_utc TEXT,
|
||||||
|
error_message TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_cash_cache_jobs_company
|
||||||
|
ON cash_payment_cache_jobs(client_id, tally_guid, started_at_utc);
|
||||||
|
CREATE TABLE IF NOT EXISTS cash_payment_cache_days (
|
||||||
|
job_id TEXT NOT NULL,
|
||||||
|
voucher_date TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
voucher_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
updated_at_utc TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(job_id, voucher_date),
|
||||||
|
FOREIGN KEY(job_id) REFERENCES cash_payment_cache_jobs(job_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_cash_cache_days_status
|
||||||
|
ON cash_payment_cache_days(job_id, status, voucher_date);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cash_cache_job_row(self, client_id: int, job_id: str = "") -> dict[str, Any] | None:
|
||||||
|
self._ensure_cash_cache_schema(client_id)
|
||||||
|
with self.store.connect(client_id) as db:
|
||||||
|
if job_id:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT * FROM cash_payment_cache_jobs WHERE job_id=? AND client_id=? LIMIT 1",
|
||||||
|
(job_id, client_id),
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT * FROM cash_payment_cache_jobs WHERE client_id=? ORDER BY started_at_utc DESC LIMIT 1",
|
||||||
|
(client_id,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def _cash_cache_update(self, client_id: int, job_id: str, **values: Any) -> None:
|
||||||
|
if not values:
|
||||||
|
return
|
||||||
|
values["updated_at_utc"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
cols = list(values)
|
||||||
|
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])
|
||||||
|
|
||||||
|
def _cash_payment_cache_start(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
client_id = int(payload.get("client_id"))
|
||||||
|
tally_guid = str(payload.get("tally_guid") or "").strip()
|
||||||
|
date_from = str(payload.get("date_from") or "").strip()
|
||||||
|
date_to = str(payload.get("date_to") or "").strip()
|
||||||
|
if not tally_guid:
|
||||||
|
raise ValueError("Select a mapped Tally company before starting extraction.")
|
||||||
|
try:
|
||||||
|
start_day = _dt_date.fromisoformat(date_from)
|
||||||
|
end_day = _dt_date.fromisoformat(date_to)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("Cash payment cache dates must be valid YYYY-MM-DD dates.") from exc
|
||||||
|
if end_day < start_day:
|
||||||
|
raise ValueError("Cash payment cache To date cannot be before From date.")
|
||||||
|
if not self.store.exists(client_id):
|
||||||
|
raise ValueError("Accounting local storage is not initialized for this client.")
|
||||||
|
self._ensure_cash_cache_schema(client_id)
|
||||||
|
|
||||||
|
# If a previous identical job was interrupted/paused, resume it instead of
|
||||||
|
# discarding already cached days. Completed jobs start a fresh refresh.
|
||||||
|
with self.store.connect(client_id) as db:
|
||||||
|
prior = db.execute(
|
||||||
|
"""SELECT * FROM cash_payment_cache_jobs
|
||||||
|
WHERE client_id=? AND tally_guid=? AND date_from=? AND date_to=?
|
||||||
|
ORDER BY started_at_utc DESC LIMIT 1""",
|
||||||
|
(client_id, tally_guid, date_from, date_to),
|
||||||
|
).fetchone()
|
||||||
|
prior = dict(prior) if prior else None
|
||||||
|
with _CASH_CACHE_THREADS_LOCK:
|
||||||
|
if _CASH_CACHE_BUSY.is_set():
|
||||||
|
if prior and str(prior.get("status") or "") in {"queued", "running", "cooling"}:
|
||||||
|
return {"job": self._cash_payment_cache_status({"client_id": client_id, "job_id": prior["job_id"]})["job"], "resumed": False, "already_running": True}
|
||||||
|
raise ValueError("Another Tally extraction is already running on this workstation. Please wait for it to finish.")
|
||||||
|
|
||||||
|
company, company_name = self._resolve_open_company(payload)
|
||||||
|
if str(company.get("guid") or "").strip() != tally_guid:
|
||||||
|
raise ValueError("The selected Tally company is not the currently resolved company.")
|
||||||
|
mapping = self.store.get_active_mapping_by_guid(client_id, tally_guid)
|
||||||
|
pause_seconds = max(2.0, min(float(payload.get("tally_pause_seconds") or 3.0), 15.0))
|
||||||
|
settings = {
|
||||||
|
"cash_limit": float(payload.get("cash_limit") or 10000.0),
|
||||||
|
"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"),
|
||||||
|
}
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
resume = bool(prior and str(prior.get("status") or "") in {"failed", "paused", "interrupted", "running", "cooling"})
|
||||||
|
job_id = str(prior.get("job_id")) if resume else str(uuid.uuid4())
|
||||||
|
total_days = (end_day - start_day).days + 1
|
||||||
|
with self.store.connect(client_id) as db:
|
||||||
|
if resume:
|
||||||
|
db.execute(
|
||||||
|
"""UPDATE cash_payment_cache_jobs SET status='queued', stage='Queued for resume', error_message=NULL,
|
||||||
|
pause_seconds=?, settings_json=?, updated_at_utc=? WHERE job_id=?""",
|
||||||
|
(pause_seconds, json.dumps(settings, separators=(",", ":")), now, job_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
db.execute(
|
||||||
|
"""INSERT INTO cash_payment_cache_jobs(
|
||||||
|
job_id, client_id, tally_guid, company_name, date_from, date_to, status, stage,
|
||||||
|
total_days, completed_days, vouchers_cached, tally_requests, retry_count,
|
||||||
|
pause_seconds, settings_json, started_at_utc, updated_at_utc
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, 'queued', 'Queued', ?, 0, 0, 0, 0, ?, ?, ?, ?)""",
|
||||||
|
(job_id, client_id, tally_guid, company_name, date_from, date_to, total_days, pause_seconds,
|
||||||
|
json.dumps(settings, separators=(",", ":")), now, now),
|
||||||
|
)
|
||||||
|
cursor = start_day
|
||||||
|
while cursor <= end_day:
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO cash_payment_cache_days(job_id, voucher_date, status, updated_at_utc) VALUES (?, ?, 'pending', ?)",
|
||||||
|
(job_id, cursor.isoformat(), now),
|
||||||
|
)
|
||||||
|
cursor += _timedelta(days=1)
|
||||||
|
|
||||||
|
worker_payload = dict(payload)
|
||||||
|
worker_payload["tally_pause_seconds"] = pause_seconds
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=self._run_cash_payment_cache_job,
|
||||||
|
args=(job_id, worker_payload, dict(company), company_name, dict(mapping)),
|
||||||
|
name=f"tally-cash-cache-{job_id[:8]}",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
with _CASH_CACHE_THREADS_LOCK:
|
||||||
|
_CASH_CACHE_THREADS[job_id] = thread
|
||||||
|
thread.start()
|
||||||
|
return {"job": self._cash_payment_cache_status({"client_id": client_id, "job_id": job_id})["job"], "resumed": resume, "already_running": False}
|
||||||
|
|
||||||
|
def _run_cash_payment_cache_job(self, job_id: str, payload: dict[str, Any], company: dict[str, Any], company_name: str, mapping: dict[str, Any]) -> None:
|
||||||
|
client_id = int(payload.get("client_id"))
|
||||||
|
pause_seconds = max(2.0, min(float(payload.get("tally_pause_seconds") or 3.0), 15.0))
|
||||||
|
requested_by = payload.get("requested_by_user_id")
|
||||||
|
_CASH_TALLY_EXTRACTION_LOCK.acquire()
|
||||||
|
_CASH_CACHE_BUSY.set()
|
||||||
|
try:
|
||||||
|
self._cash_cache_update(client_id, job_id, status="running", stage="Reading cash ledger masters", current_date="")
|
||||||
|
# One master request only, then a generous cooling gap before vouchers.
|
||||||
|
masters = None
|
||||||
|
master_error = None
|
||||||
|
for attempt in range(1, 3):
|
||||||
|
try:
|
||||||
|
masters = self.tally.export_master_collection(company_name, "ledgers") or []
|
||||||
|
self._cash_cache_update(client_id, job_id, tally_requests=(self._cash_cache_job_row(client_id, job_id) or {}).get("tally_requests", 0) + 1)
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
master_error = exc
|
||||||
|
row = self._cash_cache_job_row(client_id, job_id) or {}
|
||||||
|
self._cash_cache_update(client_id, job_id, retry_count=int(row.get("retry_count") or 0) + 1, stage="Cooling after slow Tally response")
|
||||||
|
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")
|
||||||
|
time.sleep(max(5.0, pause_seconds))
|
||||||
|
|
||||||
|
with self.store.connect(client_id) as db:
|
||||||
|
pending = db.execute(
|
||||||
|
"SELECT voucher_date FROM cash_payment_cache_days WHERE job_id=? AND status!='completed' ORDER BY voucher_date",
|
||||||
|
(job_id,),
|
||||||
|
).fetchall()
|
||||||
|
for day_row in pending:
|
||||||
|
day = str(day_row[0])
|
||||||
|
self._cash_cache_update(client_id, job_id, status="running", stage="Extracting one day from TallyPrime", current_date=day)
|
||||||
|
vouchers = None
|
||||||
|
last_exc = None
|
||||||
|
for attempt in range(1, 3):
|
||||||
|
try:
|
||||||
|
row = self._cash_cache_job_row(client_id, job_id) or {}
|
||||||
|
self._cash_cache_update(client_id, job_id, tally_requests=int(row.get("tally_requests") or 0) + 1)
|
||||||
|
vouchers = self.tally.export_vouchers(company_name, day, day) or []
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
last_exc = exc
|
||||||
|
with self.store.connect(client_id) as db:
|
||||||
|
db.execute(
|
||||||
|
"UPDATE cash_payment_cache_days SET attempts=attempts+1, status='retrying', last_error=?, updated_at_utc=? WHERE job_id=? AND voucher_date=?",
|
||||||
|
(str(exc), datetime.now(timezone.utc).isoformat(), job_id, day),
|
||||||
|
)
|
||||||
|
row = self._cash_cache_job_row(client_id, job_id) or {}
|
||||||
|
self._cash_cache_update(client_id, job_id, status="cooling", stage="Tally is slow; cooling for 20 seconds before one retry", retry_count=int(row.get("retry_count") or 0) + 1)
|
||||||
|
time.sleep(20.0)
|
||||||
|
if vouchers is None:
|
||||||
|
with self.store.connect(client_id) as db:
|
||||||
|
db.execute(
|
||||||
|
"UPDATE cash_payment_cache_days SET status='failed', last_error=?, updated_at_utc=? WHERE job_id=? AND voucher_date=?",
|
||||||
|
(str(last_exc), datetime.now(timezone.utc).isoformat(), job_id, day),
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
with self.store.connect(client_id) as db:
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
agg = db.execute(
|
||||||
|
"SELECT COUNT(*), COALESCE(SUM(voucher_count),0) FROM cash_payment_cache_days WHERE job_id=? AND status='completed'",
|
||||||
|
(job_id,),
|
||||||
|
).fetchone()
|
||||||
|
self._cash_cache_update(client_id, job_id, completed_days=int(agg[0]), vouchers_cached=int(agg[1]), stage="Cooling between one-day Tally reads")
|
||||||
|
time.sleep(pause_seconds)
|
||||||
|
|
||||||
|
self._cash_cache_update(client_id, job_id, status="completed", stage="SQLite cache ready for analysis", current_date="", completed_at_utc=datetime.now(timezone.utc).isoformat())
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.exception("Cash-payment SQLite cache job failed job_id=%s: %s", job_id, exc)
|
||||||
|
self._cash_cache_update(client_id, job_id, status="paused", stage="Paused safely", error_message=str(exc))
|
||||||
|
finally:
|
||||||
|
_CASH_CACHE_BUSY.clear()
|
||||||
|
_CASH_TALLY_EXTRACTION_LOCK.release()
|
||||||
|
with _CASH_CACHE_THREADS_LOCK:
|
||||||
|
_CASH_CACHE_THREADS.pop(job_id, None)
|
||||||
|
|
||||||
|
def _cash_payment_cache_status(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
client_id = int(payload.get("client_id"))
|
||||||
|
job_id = str(payload.get("job_id") or "").strip()
|
||||||
|
row = self._cash_cache_job_row(client_id, job_id)
|
||||||
|
if not row:
|
||||||
|
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)))
|
||||||
|
return {
|
||||||
|
"job": {
|
||||||
|
"job_id": row["job_id"],
|
||||||
|
"status": row.get("status"),
|
||||||
|
"stage": row.get("stage"),
|
||||||
|
"current_date": row.get("current_date"),
|
||||||
|
"date_from": row.get("date_from"),
|
||||||
|
"date_to": row.get("date_to"),
|
||||||
|
"total_days": total,
|
||||||
|
"completed_days": completed,
|
||||||
|
"percent": percent,
|
||||||
|
"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),
|
||||||
|
"pause_seconds": float(row.get("pause_seconds") or 0),
|
||||||
|
"error": row.get("error_message") or "",
|
||||||
|
"started_at_utc": row.get("started_at_utc"),
|
||||||
|
"updated_at_utc": row.get("updated_at_utc"),
|
||||||
|
"completed_at_utc": row.get("completed_at_utc"),
|
||||||
|
"background": True,
|
||||||
|
"sqlite_cache": True,
|
||||||
|
"can_resume": str(row.get("status") or "") in {"paused", "failed", "interrupted"},
|
||||||
|
},
|
||||||
|
"agent": self._agent_info(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _cash_payment_cache_analyze(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date as _date
|
||||||
|
client_id = int(payload.get("client_id"))
|
||||||
|
job_id = str(payload.get("job_id") or "").strip()
|
||||||
|
row = self._cash_cache_job_row(client_id, job_id)
|
||||||
|
if not row:
|
||||||
|
raise ValueError("Cash payment cache job was not found.")
|
||||||
|
if str(row.get("status") or "") != "completed":
|
||||||
|
raise ValueError("Tally extraction is not complete yet. Wait for the SQLite cache to finish before analysis.")
|
||||||
|
tally_guid = str(row.get("tally_guid") or "")
|
||||||
|
date_from = str(row.get("date_from") or "")
|
||||||
|
date_to = str(row.get("date_to") or "")
|
||||||
|
settings = json.loads(str(row.get("settings_json") or "{}"))
|
||||||
|
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"}
|
||||||
|
|
||||||
|
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),
|
||||||
|
).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],
|
||||||
|
})
|
||||||
|
|
||||||
|
payments = []
|
||||||
|
for voucher in vouchers:
|
||||||
|
if str(voucher.get("is_cancelled") or "").strip().lower() in {"yes", "true", "1"}:
|
||||||
|
continue
|
||||||
|
if str(voucher.get("is_optional") or "").strip().lower() in {"yes", "true", "1"}:
|
||||||
|
continue
|
||||||
|
entries = list(voucher.get("ledger_entries") or [])
|
||||||
|
cash_credit = []
|
||||||
|
for entry in entries:
|
||||||
|
lname = str(entry.get("ledger_name") or "").strip()
|
||||||
|
if lname.casefold() not in cash_ledgers:
|
||||||
|
continue
|
||||||
|
amount = float(entry.get("amount") or 0)
|
||||||
|
deemed = str(entry.get("is_deemed_positive") or "").strip().lower()
|
||||||
|
if amount > 0 or deemed == "no":
|
||||||
|
cash_credit.append(abs(amount))
|
||||||
|
cash_amount = round(sum(cash_credit), 2)
|
||||||
|
if cash_amount <= 0:
|
||||||
|
continue
|
||||||
|
non_cash = [e for e in entries if str(e.get("ledger_name") or "").strip().casefold() not in cash_ledgers]
|
||||||
|
party = str(voucher.get("party_ledger_name") or "").strip()
|
||||||
|
if not party or party.casefold() in cash_ledgers:
|
||||||
|
candidates = sorted(non_cash, key=lambda e: abs(float(e.get("amount") or 0)), reverse=True)
|
||||||
|
party = str(candidates[0].get("ledger_name") or "").strip() if candidates else "Unidentified counter-ledger"
|
||||||
|
payments.append({
|
||||||
|
"date": str(voucher.get("date") or voucher.get("effective_date") or ""), "party": party or "Unidentified counter-ledger",
|
||||||
|
"amount": cash_amount, "voucher_number": str(voucher.get("voucher_number") or ""),
|
||||||
|
"voucher_type": str(voucher.get("voucher_type_name") or ""), "reference": str(voucher.get("reference") or ""),
|
||||||
|
"narration": str(voucher.get("narration") or ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
by_party_date = defaultdict(list)
|
||||||
|
for item in payments:
|
||||||
|
by_party_date[(item["party"].casefold(), item["date"])].append(item)
|
||||||
|
exceptions, seen = [], set()
|
||||||
|
single_count = same_day_count = 0
|
||||||
|
for item in payments:
|
||||||
|
if item["amount"] > cash_limit + 0.009:
|
||||||
|
single_count += 1
|
||||||
|
key = (item["party"].casefold(), item["date"], "single", item["voucher_number"])
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key); exceptions.append({"date": item["date"], "party": item["party"], "amount": item["amount"], "reason": "Single cash-payment voucher exceeds configured limit", "voucher_numbers": [item["voucher_number"] or "-"]})
|
||||||
|
for (party_key, paid_on), rows in by_party_date.items():
|
||||||
|
total = round(sum(x["amount"] for x in rows), 2)
|
||||||
|
if total > cash_limit + 0.009 and len(rows) > 1:
|
||||||
|
same_day_count += 1
|
||||||
|
key = (party_key, paid_on, "aggregate")
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key); exceptions.append({"date": paid_on, "party": rows[0]["party"], "amount": total, "reason": "Same-day aggregate cash payments to the same party exceed configured limit", "voucher_numbers": [x["voucher_number"] or "-" for x in rows]})
|
||||||
|
daily_by_party = defaultdict(lambda: defaultdict(float))
|
||||||
|
for item in payments:
|
||||||
|
daily_by_party[item["party"].casefold()][item["date"]] += item["amount"]
|
||||||
|
near_floor = cash_limit * near_limit_percent / 100.0
|
||||||
|
split_patterns = []
|
||||||
|
for party_key, day_map in daily_by_party.items():
|
||||||
|
day_rows = sorted((_date.fromisoformat(day), round(amount, 2)) for day, amount in day_map.items() if day)
|
||||||
|
for start_idx in range(len(day_rows)):
|
||||||
|
window = []
|
||||||
|
for idx in range(start_idx, len(day_rows)):
|
||||||
|
d, amount = day_rows[idx]
|
||||||
|
if (d - day_rows[start_idx][0]).days >= split_window_days:
|
||||||
|
break
|
||||||
|
window.append((d, amount))
|
||||||
|
if len(window) < 2 or any(amount > cash_limit + 0.009 for _, amount in window):
|
||||||
|
continue
|
||||||
|
total = round(sum(amount for _, amount in window), 2)
|
||||||
|
if total <= cash_limit + 0.009 or sum(1 for _, amount in window if amount >= near_floor) < 2:
|
||||||
|
continue
|
||||||
|
party_name = next((x["party"] for x in payments if x["party"].casefold() == party_key), party_key)
|
||||||
|
signature = (party_key, window[0][0].isoformat(), window[-1][0].isoformat())
|
||||||
|
if any((r["party"].casefold(), r["date_from"], r["date_to"]) == signature for r in split_patterns):
|
||||||
|
continue
|
||||||
|
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()))
|
||||||
|
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),
|
||||||
|
"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,
|
||||||
|
"cache_job_id": job_id, "cache_completed_at_utc": row.get("completed_at_utc"),
|
||||||
|
"tally_request_count": int(row.get("tally_requests") or 0), "tally_retry_count": int(row.get("retry_count") or 0),
|
||||||
|
"tally_pause_ms": int(float(row.get("pause_seconds") or 0) * 1000),
|
||||||
|
"review_note": "Analysis ran entirely from the Local Agent SQLite cache after Tally extraction completed. Possible split-payment patterns are review indicators only.",
|
||||||
|
},
|
||||||
|
"agent": self._agent_info(),
|
||||||
|
}
|
||||||
|
|
||||||
def _cash_payment_compliance(self, payload: dict[str, Any]) -> dict[str, Any]:
|
def _cash_payment_compliance(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import date as _date
|
from datetime import date as _date
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Any
|
|||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
from .commands import AgentCommandProcessor
|
from .commands import AgentCommandProcessor, is_tally_cache_busy
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .tally import TallyLiveConnector
|
from .tally import TallyLiveConnector
|
||||||
from .workstation_identity import load_or_create
|
from .workstation_identity import load_or_create
|
||||||
@@ -199,7 +199,10 @@ class StorageAgentTunnel:
|
|||||||
|
|
||||||
def _workstation_status(self, force_tally: bool = False) -> dict[str, Any]:
|
def _workstation_status(self, force_tally: bool = False) -> dict[str, Any]:
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if force_tally or now - self._last_tally_status_at >= 30:
|
# While the background SQLite cache is deliberately reading Tally one day
|
||||||
|
# at a time, do not inject extra heartbeat/status XML calls into TallyPrime.
|
||||||
|
# The tunnel remains responsive and reuses the last known Tally status.
|
||||||
|
if not is_tally_cache_busy() and (force_tally or now - self._last_tally_status_at >= 30):
|
||||||
try:
|
try:
|
||||||
self._cached_tally_status = self.tally.status()
|
self._cached_tally_status = self.tally.status()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
Reference in New Issue
Block a user