Move cash payment compliance to SQLite accounting mirror
This commit is contained in:
@@ -153,6 +153,12 @@ def cash_payment_review(
|
||||
saved: int = 0,
|
||||
error: str = "",
|
||||
):
|
||||
"""Cash Payment Compliance from the local Accounting Mirror only.
|
||||
|
||||
TallyPrime is not contacted by this review route. The Local Agent is used
|
||||
only to read the client's local .act mirror database through the existing
|
||||
secure tunnel. Users refresh that mirror separately from /tools/tally.
|
||||
"""
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_partner(request, db, "accounting.tally.view")
|
||||
@@ -170,56 +176,66 @@ def cash_payment_review(
|
||||
fy_start_year = int(fy.split("-", 1)[0])
|
||||
start_text = date_from or date(fy_start_year, 4, 1).isoformat()
|
||||
end_text = date_to or today.isoformat()
|
||||
live_result = None
|
||||
|
||||
review = None
|
||||
cache_job = None
|
||||
mirror_status = None
|
||||
command_error = error or ""
|
||||
|
||||
if selected_client and node and online:
|
||||
if selected_client:
|
||||
if not node or not online:
|
||||
command_error = command_error or (
|
||||
"ERP Local Agent is offline. Cash Payment Review reads the client's local "
|
||||
"SQLite Accounting Mirror, so the storage agent must be online."
|
||||
)
|
||||
else:
|
||||
storage_payload = _accounting_storage_payload(
|
||||
selected_client,
|
||||
_financial_year_for_date(date.fromisoformat(start_text)),
|
||||
)
|
||||
try:
|
||||
status_response = request_agent_command(
|
||||
node.node_code,
|
||||
"phase4_status",
|
||||
_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))),
|
||||
"accounting_mirror_status",
|
||||
storage_payload,
|
||||
timeout_seconds=20,
|
||||
)
|
||||
if status_response.get("ok"):
|
||||
live_result = status_response.get("result") or {}
|
||||
mirror_status = status_response.get("result") or {}
|
||||
else:
|
||||
command_error = str(status_response.get("error") or "Local Agent status failed.")
|
||||
|
||||
if job_id:
|
||||
status = request_agent_command(
|
||||
node.node_code,
|
||||
"accounting_cash_payment_cache_status",
|
||||
{
|
||||
**_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))),
|
||||
"job_id": str(job_id),
|
||||
},
|
||||
timeout_seconds=20,
|
||||
command_error = str(
|
||||
status_response.get("error") or "Could not read the local Accounting Mirror status."
|
||||
)
|
||||
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.")
|
||||
except Exception as exc:
|
||||
command_error = str(exc)
|
||||
|
||||
if analyze and job_id and cache_job and cache_job.get("status") == "completed":
|
||||
if analyze and not command_error:
|
||||
try:
|
||||
result = request_agent_command(
|
||||
node.node_code,
|
||||
"accounting_cash_payment_cache_analyze",
|
||||
"accounting_cash_payment_mirror_analyze",
|
||||
{
|
||||
**_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))),
|
||||
"job_id": str(job_id),
|
||||
**storage_payload,
|
||||
"date_from": start_text,
|
||||
"date_to": end_text,
|
||||
"cash_ledger_name": str(cash_ledger_name or "").strip(),
|
||||
"cash_limit": float(setting.cash_limit),
|
||||
"split_window_days": int(setting.split_window_days),
|
||||
"near_limit_percent": float(setting.near_limit_percent),
|
||||
"requested_by_user_id": int(user.id),
|
||||
},
|
||||
timeout_seconds=120,
|
||||
)
|
||||
if result.get("ok"):
|
||||
review = (result.get("result") or {}).get("cash_payment_review")
|
||||
body = result.get("result") or {}
|
||||
review = body.get("cash_payment_review")
|
||||
mirror_status = {
|
||||
**(mirror_status or {}),
|
||||
"mirror": body.get("mirror") or (mirror_status or {}).get("mirror") or {},
|
||||
}
|
||||
else:
|
||||
command_error = str(result.get("error") or "Cash payment SQLite analysis failed.")
|
||||
command_error = str(
|
||||
result.get("error") or "Cash payment SQLite mirror analysis failed."
|
||||
)
|
||||
except Exception as exc:
|
||||
command_error = str(exc)
|
||||
|
||||
@@ -232,14 +248,15 @@ def cash_payment_review(
|
||||
selected_client=selected_client,
|
||||
storage_node=node,
|
||||
agent_online=online,
|
||||
live_result=live_result,
|
||||
live_result=None,
|
||||
mirror_status=mirror_status,
|
||||
selected_tally_guid=str(tally_guid or ""),
|
||||
date_from=start_text,
|
||||
date_to=end_text,
|
||||
setting=setting,
|
||||
review=review,
|
||||
cache_job=cache_job,
|
||||
active_job_id=str(job_id or ""),
|
||||
cache_job=None,
|
||||
active_job_id="",
|
||||
selected_cash_ledger_name=str(cash_ledger_name or ""),
|
||||
saved=bool(saved),
|
||||
command_error=command_error,
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
<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 · Tally</p>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting · SQLite Mirror</p>
|
||||
<h1 class="mt-1 text-2xl font-semibold text-slate-900">{{ 'Cash Payment Entry Allocation' if page_mode == 'allocation' else 'Cash Payment Compliance Review' }}</h1>
|
||||
{% if page_mode == 'allocation' %}
|
||||
<p class="mt-1 max-w-4xl text-sm text-slate-600">Prepare and validate cash payment entry allocation using only actual source payment dates. The tool checks totals, the configured daily limit and the permitted source-date window; it never invents or changes dates.</p>
|
||||
{% else %}
|
||||
<p class="mt-1 max-w-4xl text-sm text-slate-600">Read-only Tally review for cash payments above the configured limit, same-day party aggregation and possible split-payment patterns.</p>
|
||||
<p class="mt-1 max-w-4xl text-sm text-slate-600">Analyse cash-payment compliance directly from the client's local SQLite Accounting Mirror. TallyPrime is not contacted during this review.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Back to Tally</a>
|
||||
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Back to Tally Mirror</a>
|
||||
</div>
|
||||
|
||||
{% if saved %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Cash payment rule settings saved.</div>{% endif %}
|
||||
@@ -20,9 +20,20 @@
|
||||
{% if page_mode != 'allocation' %}
|
||||
<section class="grid gap-4 lg:grid-cols-3">
|
||||
<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>
|
||||
<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-5">
|
||||
<input type="hidden" id="cash-csrf-token" value="{{ csrf_token }}">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-slate-900">Review from Accounting Mirror</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">No Tally connection, company selection, extraction queue or day-by-day cache is used here.</p>
|
||||
</div>
|
||||
{% set mirror = (mirror_status.mirror if mirror_status and mirror_status.mirror else {}) %}
|
||||
{% if mirror.ready %}
|
||||
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">SQLite Mirror Ready</span>
|
||||
{% else %}
|
||||
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-800">Mirror Required</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="get" action="/tools/accounting/cash-payments" class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div>
|
||||
<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()">
|
||||
@@ -30,66 +41,38 @@
|
||||
{% 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>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold uppercase text-slate-500">Tally Company</label>
|
||||
<select id="cash-tally-company" name="tally_guid" class="mt-1 w-full rounded-xl border-slate-300 text-sm" {% if selected_client %}required{% endif %}>
|
||||
<option value="">Select open Tally company</option>
|
||||
{% set mapped_guids = [] %}
|
||||
{% if live_result and live_result.accounting %}
|
||||
{% for mapping in live_result.accounting.mappings or [] %}
|
||||
{% if mapping.tally_guid %}{% set _ = mapped_guids.append(mapping.tally_guid) %}{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if live_result and live_result.tally %}
|
||||
{% for company in live_result.tally.companies or [] %}
|
||||
{% set is_mapped = company.guid in mapped_guids %}
|
||||
<option value="{{ company.guid }}" data-company-name="{{ company.name or '' }}" data-open-company="1" {% if selected_tally_guid == company.guid or (not selected_tally_guid and is_mapped) %}selected{% endif %}>{{ company.name or company.guid }}{% if is_mapped %} • Mapped{% endif %}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if live_result and live_result.accounting %}
|
||||
{% for mapping in live_result.accounting.mappings or [] %}
|
||||
{% if mapping.tally_guid and mapping.tally_guid not in ((live_result.tally.companies or [])|map(attribute='guid')|list if live_result and live_result.tally else []) %}
|
||||
<option value="{{ mapping.tally_guid }}" data-company-name="{{ mapping.company_name or mapping.tally_company_name or '' }}" data-open-company="0" {% if selected_tally_guid == mapping.tally_guid %}selected{% endif %}>{{ mapping.company_name or mapping.tally_company_name or mapping.tally_guid }} • Mapped (not open)</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold uppercase text-slate-500">Ledger Scope</label>
|
||||
<select id="cash-ledger-scope" name="ledger_scope" class="mt-1 w-full rounded-xl border-slate-300 text-xs">
|
||||
<option value="cash" selected>Ledgers under Cash-in-Hand</option>
|
||||
<option value="all">All Tally ledgers — manual selection</option>
|
||||
</select>
|
||||
|
||||
<label class="mt-2 block text-xs font-semibold uppercase text-slate-500">Cash Ledger Selection</label>
|
||||
<select id="cash-ledger-select" name="cash_ledger_name" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required>
|
||||
<option value="">Select Cash ledger</option>
|
||||
{% if selected_cash_ledger_name %}<option value="{{ selected_cash_ledger_name }}" selected>{{ selected_cash_ledger_name }}</option>{% endif %}
|
||||
</select>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" id="cash-load-ledgers" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700">Load Cash Ledgers</button>
|
||||
<button type="button" id="cash-refresh-ledgers" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700">Refresh Master Data</button>
|
||||
</div>
|
||||
|
||||
<p id="cash-ledger-help" class="mt-1 text-xs text-slate-500">Load Cash Ledgers reads the common master from the client .act SQLite database only. Refresh Master Data explicitly refreshes that common master from TallyPrime.</p>
|
||||
<div id="cash-ledger-progress" class="mt-2 hidden rounded-xl border border-sky-200 bg-sky-50 p-3">
|
||||
<div class="flex items-center justify-between text-xs font-semibold text-sky-900"><span id="cash-ledger-progress-stage">Preparing Cash ledger list…</span><span id="cash-ledger-progress-pct">0%</span></div>
|
||||
<div class="mt-2 h-2 overflow-hidden rounded-full bg-sky-100"><div id="cash-ledger-progress-bar" class="h-full rounded-full bg-sky-600 transition-all duration-500" style="width:0%"></div></div>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 text-[11px] sm:grid-cols-4">
|
||||
<div id="cash-master-step-1" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">1. Local Master</div><div class="mt-1 text-slate-500">.act SQLite</div></div>
|
||||
<div id="cash-master-step-2" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">2. Cash Filter</div><div class="mt-1 text-slate-500">Cash-in-Hand</div></div>
|
||||
<div id="cash-master-step-3" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">3. Transfer to VPS</div><div class="mt-1 text-slate-500">Tunnel result</div></div>
|
||||
<div id="cash-master-step-4" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">4. Ledger Ready</div><div class="mt-1 text-slate-500">Dropdown</div></div>
|
||||
</div>
|
||||
<div id="cash-master-meta" class="mt-2 text-[11px] text-slate-500">Master source: local .act SQLite. Normal loading never queries TallyPrime.</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">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 class="md:col-span-2 xl:col-span-5"><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 %}>Review Cash Transactions</button><p class="mt-2 text-xs text-slate-500">Select the Tally company and Cash ledger from the local .act master snapshot. Only the selected ledger’s relevant transactions are then extracted from Tally into SQLite; compliance analysis runs from SQLite. Existing threshold rules, allocation workflow and dual VPS/Local Agent progress are preserved.</p></div>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" name="analyze" value="1" class="w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50" {% if not selected_client or not mirror.ready %}disabled{% endif %}>Review Cash Payments</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if selected_client %}
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-xl bg-slate-50 p-3">
|
||||
<div class="text-xs font-semibold uppercase text-slate-500">Mirror Company</div>
|
||||
<div class="mt-1 text-sm font-semibold text-slate-900">{{ mirror.company.company_name if mirror.ready and mirror.company else 'Not mirrored' }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-slate-50 p-3">
|
||||
<div class="text-xs font-semibold uppercase text-slate-500">Mirror Period</div>
|
||||
<div class="mt-1 text-sm font-semibold text-slate-900">{% if mirror.ready and mirror.voucher_period %}{{ mirror.voucher_period.from_date or '—' }} → {{ mirror.voucher_period.to_date or '—' }}{% else %}—{% endif %}</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-slate-50 p-3">
|
||||
<div class="text-xs font-semibold uppercase text-slate-500">Vouchers Available</div>
|
||||
<div class="mt-1 text-sm font-semibold text-slate-900">{{ mirror.counts.voucher if mirror.ready and mirror.counts else 0 }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-slate-50 p-3">
|
||||
<div class="text-xs font-semibold uppercase text-slate-500">Voucher Lines</div>
|
||||
<div class="mt-1 text-sm font-semibold text-slate-900">{{ mirror.counts.voucher_ledger_entry if mirror.ready and mirror.counts else 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if mirror.ready %}
|
||||
<p class="mt-3 break-all text-xs text-slate-500">Source: {{ mirror.path or mirror_status.mirror_db_path or '' }}</p>
|
||||
{% else %}
|
||||
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900">Mirror this client first from <strong>Tally Accounting → Mirror Tally to SQLite</strong>. After that, Cash Payment Review works even if TallyPrime is closed.</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
@@ -106,25 +89,13 @@
|
||||
</div>
|
||||
</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 }} Cash-ledger voucher(s)</div>{% if cache_job.cash_ledgers %}<div>Ledger: {{ cache_job.cash_ledgers|join(', ') }}</div>{% endif %}</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 %}
|
||||
<section class="space-y-4">
|
||||
{% if review.sqlite_cache %}
|
||||
<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 %}
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
Analysis completed entirely from the local SQLite Accounting Mirror. TallyPrime was not contacted.
|
||||
{% if review.cash_ledgers %} Cash ledger(s): <strong>{{ review.cash_ledgers|join(', ') }}</strong>.{% endif %}
|
||||
{% if review.mirror_period %} Mirror period: {{ review.mirror_period.from_date or '—' }} → {{ review.mirror_period.to_date or '—' }}.{% endif %}
|
||||
</div>
|
||||
<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)] %}
|
||||
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">{{ label }}</div><div class="mt-2 text-2xl font-semibold text-slate-900">{{ value }}</div></div>
|
||||
@@ -147,8 +118,6 @@
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% if page_mode == 'allocation' %}
|
||||
@@ -177,394 +146,5 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
{% 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 class="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Cash Ledger Extraction & Compliance Progress</h3>
|
||||
<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 id="cash-progress-percent" class="text-lg font-semibold text-brand-700">0%</div>
|
||||
</div>
|
||||
<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-500" style="width:0%"></div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-2 gap-3 text-sm md:grid-cols-4">
|
||||
<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">Cash vouchers</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 class="mt-4 grid gap-2 text-xs md:grid-cols-5">
|
||||
<div id="erp-cash-step-1" class="rounded-lg bg-slate-100 px-3 py-2">1. Connect</div>
|
||||
<div id="erp-cash-step-2" class="rounded-lg bg-slate-100 px-3 py-2">2. Confirm ledger</div>
|
||||
<div id="erp-cash-step-3" class="rounded-lg bg-slate-100 px-3 py-2">3. Extract</div>
|
||||
<div id="erp-cash-step-4" class="rounded-lg bg-slate-100 px-3 py-2">4. Analyse</div>
|
||||
<div id="erp-cash-step-5" class="rounded-lg bg-slate-100 px-3 py-2">5. Complete</div>
|
||||
</div>
|
||||
<p class="mt-4 text-xs text-slate-500">The confirmed Cash ledger comes from the common local .act master snapshot. The Local Agent then reads Tally in safe one-day batches and stores only vouchers containing that ledger in SQLite. Analysis runs from SQLite. You can close this ERP page; the background job continues and remains visible on the Local Agent dashboard.</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 gap-2">
|
||||
<button type="button" id="cash-progress-cancel" class="hidden rounded-xl border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-900">Cancel Extraction</button>
|
||||
<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>
|
||||
<script>
|
||||
(function () {
|
||||
const form = document.getElementById('cash-review-form');
|
||||
const button = document.getElementById('cash-review-button');
|
||||
if (!form || !button) return;
|
||||
const overlay = document.getElementById('cash-review-progress');
|
||||
const bar = document.getElementById('cash-progress-bar');
|
||||
const pct = document.getElementById('cash-progress-percent');
|
||||
const text = document.getElementById('cash-progress-text');
|
||||
const days = document.getElementById('cash-progress-days');
|
||||
const vouchers = document.getElementById('cash-progress-vouchers');
|
||||
const requests = document.getElementById('cash-progress-requests');
|
||||
const currentDate = document.getElementById('cash-progress-date');
|
||||
const errorBox = document.getElementById('cash-progress-error');
|
||||
const closeButton = document.getElementById('cash-progress-close');
|
||||
const cancelButton = document.getElementById('cash-progress-cancel');
|
||||
let pollTimer = null;
|
||||
let activeJobId = '';
|
||||
let activeClientId = '';
|
||||
let activeDateFrom = '';
|
||||
const ledgerButton = document.getElementById('cash-load-ledgers');
|
||||
const ledgerSelect = document.getElementById('cash-ledger-select');
|
||||
const ledgerScope = document.getElementById('cash-ledger-scope');
|
||||
const ledgerHelp = document.getElementById('cash-ledger-help');
|
||||
let allLedgerRows = [];
|
||||
let cashLedgerRows = [];
|
||||
let lastLedgerMeta = {ledger_count:0, company_name:'', from_cache:false};
|
||||
|
||||
const ledgerRefreshButton = document.getElementById('cash-refresh-ledgers');
|
||||
const ledgerProgress = document.getElementById('cash-ledger-progress');
|
||||
const ledgerProgressBar = document.getElementById('cash-ledger-progress-bar');
|
||||
const ledgerProgressPct = document.getElementById('cash-ledger-progress-pct');
|
||||
const ledgerProgressStage = document.getElementById('cash-ledger-progress-stage');
|
||||
const masterStep1 = document.getElementById('cash-master-step-1');
|
||||
const masterStep2 = document.getElementById('cash-master-step-2');
|
||||
const masterStep3 = document.getElementById('cash-master-step-3');
|
||||
const masterStep4 = document.getElementById('cash-master-step-4');
|
||||
const masterMeta = document.getElementById('cash-master-meta');
|
||||
let ledgerPollTimer = null;
|
||||
|
||||
function paintMasterSteps(step) {
|
||||
[masterStep1, masterStep2, masterStep3, masterStep4].forEach(function(el, idx) {
|
||||
if (!el) return;
|
||||
el.className = 'rounded-lg border px-2 py-2 text-center ' + (idx < step
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-900'
|
||||
: 'border-slate-200 bg-slate-50 text-slate-700');
|
||||
});
|
||||
}
|
||||
|
||||
function renderLedgerOptions() {
|
||||
const previous = ledgerSelect.value;
|
||||
const rows = (ledgerScope && ledgerScope.value === 'all') ? allLedgerRows : cashLedgerRows;
|
||||
ledgerSelect.innerHTML = '<option value="">Select Cash ledger</option>';
|
||||
rows.forEach(function(row){
|
||||
const opt=document.createElement('option');
|
||||
opt.value=row.name;
|
||||
opt.textContent=row.name + (row.parent ? ' — '+row.parent : '') + (row.is_cash_candidate ? ' • Cash-in-Hand' : '');
|
||||
if (previous && previous===row.name) opt.selected=true;
|
||||
ledgerSelect.appendChild(opt);
|
||||
});
|
||||
if (previous && !rows.some(row => row.name === previous)) ledgerSelect.value='';
|
||||
const company = lastLedgerMeta.company_name || 'Tally';
|
||||
if (ledgerScope && ledgerScope.value === 'all') {
|
||||
ledgerHelp.textContent = String(rows.length)+' of '+String(lastLedgerMeta.ledger_count || allLedgerRows.length)+' ledger(s) available from '+company+'. Select the required ledger manually.';
|
||||
} else if (rows.length) {
|
||||
ledgerHelp.textContent = String(rows.length)+' Cash-in-Hand ledger(s) found in '+company+'. Select the ledger whose transactions should be reviewed.';
|
||||
} else if (allLedgerRows.length) {
|
||||
ledgerHelp.textContent = 'No Cash-in-Hand ledger was identified from Tally grouping. Choose “All Tally ledgers — manual selection” to select the required ledger.';
|
||||
} else {
|
||||
ledgerHelp.textContent = 'No ledgers are available yet for the selected Tally company.';
|
||||
}
|
||||
}
|
||||
|
||||
function populateLedgers(data) {
|
||||
allLedgerRows = Array.isArray(data.ledgers) ? data.ledgers.slice() : [];
|
||||
cashLedgerRows = Array.isArray(data.cash_candidates) ? data.cash_candidates.slice() : allLedgerRows.filter(row => row.is_cash_candidate);
|
||||
lastLedgerMeta = {
|
||||
ledger_count: Number(data.ledger_count || allLedgerRows.length || 0),
|
||||
company_name: String(data.company_name || ''),
|
||||
from_cache: Boolean(data.from_cache)
|
||||
};
|
||||
renderLedgerOptions();
|
||||
paintMasterSteps(4);
|
||||
if (masterMeta) {
|
||||
const synced = data.master_synced_at_utc ? new Date(data.master_synced_at_utc).toLocaleString() : 'time not available';
|
||||
const source = data.master_refreshed ? 'TallyPrime → local .act SQLite (refreshed now)' : 'local .act SQLite';
|
||||
masterMeta.textContent = 'Master source: ' + source + ' · Last master sync: ' + synced + ' · ' + String(data.total_ledgers_examined || data.ledger_count || 0) + ' ledger(s) in common snapshot.';
|
||||
}
|
||||
}
|
||||
|
||||
function updateLedgerProgress(job) {
|
||||
const p=Math.max(0,Math.min(100,Number(job.percent||0)));
|
||||
ledgerProgress.classList.remove('hidden');
|
||||
ledgerProgressBar.style.width=p+'%';
|
||||
ledgerProgressPct.textContent=Math.round(p)+'%';
|
||||
ledgerProgressStage.textContent=job.stage||job.status||'Working…';
|
||||
if (p >= 100) paintMasterSteps(4);
|
||||
else if (p >= 90) paintMasterSteps(3);
|
||||
else if (p >= 60) paintMasterSteps(2);
|
||||
else if (p >= 15) paintMasterSteps(1);
|
||||
}
|
||||
|
||||
async function pollLedgers(jobId, clientId, dateFrom, tallyGuid, companyName, selectedScope, forceRefresh) {
|
||||
try {
|
||||
const url=new URL('/tools/accounting/cash-payments/ledgers/progress',window.location.origin);
|
||||
url.searchParams.set('client_id',clientId);
|
||||
url.searchParams.set('job_id',jobId);
|
||||
url.searchParams.set('date_from',dateFrom);
|
||||
url.searchParams.set('tally_guid',tallyGuid || '');
|
||||
url.searchParams.set('company_name',companyName || '');
|
||||
url.searchParams.set('ledger_scope',selectedScope || 'cash');
|
||||
url.searchParams.set('force_refresh',forceRefresh ? '1' : '0');
|
||||
const response=await fetch(url.toString(),{headers:{'Accept':'application/json'},cache:'no-store'});
|
||||
const data=await response.json();
|
||||
if(!response.ok||!data.ok) throw new Error(data.error||'Could not read Cash ledger transfer progress.');
|
||||
const job=data.job||{};
|
||||
updateLedgerProgress(job);
|
||||
if(data.transport_pending){
|
||||
ledgerHelp.textContent='Local Agent work is queued/running. Waiting for the tunnel to deliver the ledger result to the VPS…';
|
||||
}
|
||||
if(job.status==='completed' && Array.isArray(data.ledgers) && data.ledgers.length >= 0){
|
||||
populateLedgers(data);
|
||||
ledgerButton.disabled=false;
|
||||
if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;
|
||||
ledgerProgress.classList.remove('hidden');
|
||||
ledgerProgressBar.style.width='100%';
|
||||
ledgerProgressPct.textContent='100%';
|
||||
ledgerProgressStage.textContent=String(data.cash_ledger_count || 0)+' Cash-in-Hand ledger(s) transmitted to VPS';
|
||||
paintMasterSteps(4);
|
||||
ledgerHelp.textContent=String(data.cash_ledger_count || 0)+' Cash-in-Hand ledger(s) received by VPS and ready for selection.';
|
||||
window.setTimeout(()=>ledgerProgress.classList.add('hidden'),1800);
|
||||
return;
|
||||
}
|
||||
if(job.status==='failed') throw new Error(job.error||'Cash ledger preparation failed.');
|
||||
ledgerPollTimer=window.setTimeout(
|
||||
()=>pollLedgers(jobId,clientId,dateFrom,tallyGuid,companyName,selectedScope,forceRefresh),
|
||||
1500
|
||||
);
|
||||
} catch(err) {
|
||||
ledgerHelp.textContent=err&&err.message?err.message:String(err);
|
||||
ledgerButton.disabled=false;
|
||||
if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;
|
||||
ledgerProgressStage.textContent=ledgerHelp.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCashLedgers(forceRefresh) {
|
||||
const fd = new FormData(form);
|
||||
if (!fd.get('client_id') || !fd.get('tally_guid')) { ledgerHelp.textContent = 'Select a client and Tally company first.'; return; }
|
||||
fd.set('csrf_token', document.getElementById('cash-csrf-token').value);
|
||||
const companySelect = form.querySelector('select[name="tally_guid"]');
|
||||
const companyOption = companySelect && companySelect.options[companySelect.selectedIndex];
|
||||
const companyName = companyOption ? String(companyOption.dataset.companyName || companyOption.textContent || '').trim() : '';
|
||||
if (companyName) fd.set('company_name', companyName);
|
||||
const selectedScope = ledgerScope ? String(ledgerScope.value || 'cash') : 'cash';
|
||||
fd.set('ledger_scope', selectedScope);
|
||||
if(forceRefresh) fd.set('force_refresh','1');
|
||||
ledgerButton.disabled = true; if(ledgerRefreshButton) ledgerRefreshButton.disabled=true;
|
||||
paintMasterSteps(0);
|
||||
ledgerProgress.classList.remove('hidden');
|
||||
ledgerProgressBar.style.width='15%'; ledgerProgressPct.textContent='15%';
|
||||
if (forceRefresh) {
|
||||
ledgerProgressStage.textContent='Refreshing common accounting masters from TallyPrime…';
|
||||
ledgerHelp.textContent='Refreshing the shared .act master snapshot once, then filtering the Cash-in-Hand hierarchy locally.';
|
||||
if (masterMeta) masterMeta.textContent='Master source: refreshing TallyPrime → local .act SQLite.';
|
||||
} else {
|
||||
ledgerProgressStage.textContent='Reading common ledger master from local SQLite…';
|
||||
ledgerHelp.textContent = selectedScope === 'all'
|
||||
? 'Reading all ledgers from the common local .act master snapshot…'
|
||||
: 'Reading local .act masters and selecting Cash-in-Hand ledgers…';
|
||||
if (masterMeta) masterMeta.textContent='Master source: local .act SQLite. TallyPrime is not queried for this normal load.';
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/tools/accounting/cash-payments/ledgers', {method:'POST', body:fd, headers:{'Accept':'application/json'}});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.ok) throw new Error(data.error || 'Could not load Tally ledgers.');
|
||||
if (Array.isArray(data.ledgers)) {
|
||||
populateLedgers(data);
|
||||
ledgerButton.disabled=false; if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;
|
||||
ledgerProgress.classList.remove('hidden');
|
||||
ledgerProgressBar.style.width='100%'; ledgerProgressPct.textContent='100%';
|
||||
ledgerProgressStage.textContent = data.cash_only
|
||||
? String(data.ledger_count || 0)+' Cash-in-Hand ledger(s) ready from SQLite'
|
||||
: String(data.ledger_count || 0)+' ledger(s) ready from SQLite';
|
||||
paintMasterSteps(4);
|
||||
window.setTimeout(()=>ledgerProgress.classList.add('hidden'),1200);
|
||||
return;
|
||||
}
|
||||
const job=data.job||{};
|
||||
if(!job.job_id) throw new Error('Local Agent did not return a ledger-sync job id.');
|
||||
updateLedgerProgress(job);
|
||||
if(data.transport_pending){ ledgerHelp.textContent='Command sent. Waiting for the Local Agent result to cross the tunnel back to VPS…'; }
|
||||
pollLedgers(
|
||||
job.job_id,
|
||||
String(fd.get('client_id')||''),
|
||||
String(fd.get('date_from')||''),
|
||||
String(fd.get('tally_guid')||''),
|
||||
companyName,
|
||||
selectedScope,
|
||||
Boolean(forceRefresh)
|
||||
);
|
||||
} catch(err) {
|
||||
ledgerHelp.textContent = err && err.message ? err.message : String(err);
|
||||
ledgerButton.disabled=false; if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;
|
||||
}
|
||||
}
|
||||
if (ledgerButton) ledgerButton.addEventListener('click', function(){loadCashLedgers(false);});
|
||||
if (ledgerRefreshButton) ledgerRefreshButton.addEventListener('click', function(){loadCashLedgers(true);});
|
||||
const companySelect = document.getElementById('cash-tally-company');
|
||||
if (ledgerScope) ledgerScope.addEventListener('change', function(){
|
||||
ledgerSelect.innerHTML = '<option value="">Select Cash ledger</option>';
|
||||
if (companySelect && companySelect.value) loadCashLedgers(false);
|
||||
});
|
||||
if (companySelect) companySelect.addEventListener('change', function(){
|
||||
allLedgerRows = []; cashLedgerRows = []; lastLedgerMeta = {ledger_count:0, company_name:'', from_cache:false};
|
||||
ledgerSelect.innerHTML = '<option value="">Select Cash ledger</option>';
|
||||
ledgerProgress.classList.add('hidden');
|
||||
if (this.value) {
|
||||
ledgerHelp.textContent = 'Loading Cash-in-Hand ledgers for the selected Tally company…';
|
||||
loadCashLedgers(false);
|
||||
} else {
|
||||
ledgerHelp.textContent = 'Select an open Tally company first.';
|
||||
}
|
||||
});
|
||||
if (companySelect && companySelect.value) {
|
||||
window.setTimeout(function(){ loadCashLedgers(false); }, 250);
|
||||
}
|
||||
|
||||
function show() {
|
||||
overlay.classList.remove('hidden');
|
||||
overlay.classList.add('flex');
|
||||
errorBox.classList.add('hidden');
|
||||
closeButton.classList.add('hidden');
|
||||
cancelButton.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');
|
||||
cancelButton.classList.add('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 || '—';
|
||||
const stage = String(job.stage || '').toLowerCase();
|
||||
const status = String(job.status || '').toLowerCase();
|
||||
const steps = [1,2,3,4,5].map(n => document.getElementById('erp-cash-step-'+n));
|
||||
steps.forEach(e => { if(e) e.className='rounded-lg bg-slate-100 px-3 py-2'; });
|
||||
function done(n){ if(steps[n-1]) steps[n-1].className='rounded-lg bg-emerald-50 px-3 py-2 font-semibold text-emerald-700'; }
|
||||
function running(n){ if(steps[n-1]) steps[n-1].className='rounded-lg bg-sky-50 px-3 py-2 font-semibold text-sky-700 ring-1 ring-sky-200'; }
|
||||
if (value > 0) running(1); if (value >= 10) {done(1); running(2);} if (value >= 20) {done(2); running(3);} if (stage.includes('analys')) {done(3); running(4);} if (status === 'completed' && stage.includes('analysis complete')) {done(1);done(2);done(3);done(4);done(5);} else if (status === 'completed') {done(1);done(2);done(3);running(4);}
|
||||
if (job.can_cancel) cancelButton.classList.remove('hidden'); else cancelButton.classList.add('hidden');
|
||||
}
|
||||
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('cash_ledger_name', String(document.getElementById('cash-ledger-select').value || ''));
|
||||
target.searchParams.set('analyze', '1');
|
||||
window.location.assign(target.toString());
|
||||
return;
|
||||
}
|
||||
if (job.status === 'cancelled') {
|
||||
fail('Extraction was cancelled safely. Already cached days remain available and can be resumed later.');
|
||||
return;
|
||||
}
|
||||
if (job.status === 'paused' || job.status === 'failed') {
|
||||
fail(job.error || 'Tally extraction paused. Restart/verify TallyPrime and click Review Cash Transactions 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);
|
||||
const selectedCompany = document.getElementById('cash-tally-company');
|
||||
const selectedCompanyOption = selectedCompany && selectedCompany.options[selectedCompany.selectedIndex];
|
||||
const selectedCompanyName = selectedCompanyOption ? String(selectedCompanyOption.dataset.companyName || selectedCompanyOption.textContent || '').replace(' • Mapped','').replace(' • Mapped (not open)','').trim() : '';
|
||||
if (selectedCompanyName) fd.set('company_name', selectedCompanyName);
|
||||
button.disabled = true;
|
||||
show();
|
||||
try {
|
||||
const response = await fetch('/tools/accounting/cash-payments/cache/start', {method: 'POST', body: fd, headers: {'Accept': 'application/json'}});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.ok) throw new Error(data.error || 'Could not start extraction.');
|
||||
const job = data.job || {};
|
||||
activeJobId = job.job_id || '';
|
||||
activeClientId = String(fd.get('client_id') || '');
|
||||
activeDateFrom = String(fd.get('date_from') || '');
|
||||
update(job);
|
||||
poll(job.job_id, fd.get('client_id'), fd.get('date_from'), fd.get('tally_guid'), fd.get('date_to'));
|
||||
} catch (err) {
|
||||
fail(err && err.message ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
cancelButton.addEventListener('click', async function () {
|
||||
if (!activeJobId || !activeClientId) return;
|
||||
cancelButton.disabled = true;
|
||||
text.textContent = 'Requesting a safe stop after the current Tally request…';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set('csrf_token', document.getElementById('cash-csrf-token').value);
|
||||
fd.set('client_id', activeClientId);
|
||||
fd.set('job_id', activeJobId);
|
||||
fd.set('date_from', activeDateFrom);
|
||||
const response = await fetch('/tools/accounting/cash-payments/cache/cancel', {method: 'POST', body: fd, headers: {'Accept': 'application/json'}});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.ok) throw new Error(data.error || 'Could not cancel extraction.');
|
||||
if (data.job) update(data.job);
|
||||
cancelButton.classList.add('hidden');
|
||||
} catch (err) {
|
||||
errorBox.textContent = err && err.message ? err.message : String(err);
|
||||
errorBox.classList.remove('hidden');
|
||||
cancelButton.disabled = false;
|
||||
}
|
||||
});
|
||||
closeButton.addEventListener('click', function () {
|
||||
overlay.classList.add('hidden'); overlay.classList.remove('flex');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,7 +4,7 @@ import io
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
ERP_LOCAL_AGENT_VERSION = "1.24.4"
|
||||
ERP_LOCAL_AGENT_VERSION = "1.24.6"
|
||||
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
||||
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
||||
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
||||
@@ -65,7 +65,7 @@ def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme:
|
||||
text = (
|
||||
f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n"
|
||||
"Existing storage, WebSocket tunnel, dashboard, Tally mapping and client .act functionality are preserved.\n"
|
||||
"Existing master/transaction sync, accounting tools and controlled write-back are preserved.\nAccounting Mirror uses the proven v3.0.1 ODBC runtime for ledgers, vouchers, stock movement, HSN and GST rates.\nTally TDL is exposed through a stable local path and localhost URL so later agent updates can repair/replace the TDL without changing the Tally link.\nFull Accounting Export runs asynchronously from the ERP, publishes live progress in both ERP and the Local Agent dashboard, and refreshes the client SQLite accounting datasets after the ODBC mirror is built.\n"
|
||||
"Existing master/transaction sync, accounting tools and controlled write-back are preserved.\nAccounting Mirror uses the proven v3.0.1 ODBC runtime for ledgers, vouchers, stock movement, HSN and GST rates.\nTally TDL is exposed through a stable local path and localhost URL so later agent updates can repair/replace the TDL without changing the Tally link.\nFull Accounting Export runs asynchronously from the ERP, publishes live progress in both ERP and the Local Agent dashboard, and refreshes the client SQLite accounting datasets after the ODBC mirror is built.\nCash Payment Compliance reads only the local SQLite Accounting Mirror after mirroring; it no longer starts a Tally extraction job or requires TallyPrime during review.\n"
|
||||
"Dashboard: http://127.0.0.1:8788\nDesktop dashboard is single-instance: repeated shortcut clicks focus the existing window without starting another Local Agent.\nUpdate dashboard shows check, download, install, restart and completion progress.\n"
|
||||
)
|
||||
_write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", text.encode("utf-8"))
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = "1.24.4"
|
||||
__version__ = "1.24.6"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
|
||||
@@ -144,6 +144,8 @@ class AgentCommandProcessor:
|
||||
result = self._cash_payment_cache_cancel(payload)
|
||||
elif action == "accounting_cash_payment_cache_analyze":
|
||||
result = self._cash_payment_cache_analyze(payload)
|
||||
elif action == "accounting_cash_payment_mirror_analyze":
|
||||
result = self._cash_payment_mirror_analyze(payload)
|
||||
elif action == "accounting_cash_payment_compliance":
|
||||
result = self._cash_payment_compliance(payload)
|
||||
elif action == "accounting_tds_compliance":
|
||||
@@ -208,6 +210,7 @@ class AgentCommandProcessor:
|
||||
"purchase_voucher_write_capability": True,
|
||||
"it_depreciation_capability": True,
|
||||
"cash_payment_compliance_capability": True,
|
||||
"cash_payment_mirror_only_analysis_capability": True,
|
||||
"cash_payment_sqlite_cache_capability": True,
|
||||
"cash_payment_background_sync_capability": True,
|
||||
"cash_payment_ledger_confirmation_capability": True,
|
||||
@@ -2118,6 +2121,308 @@ class AgentCommandProcessor:
|
||||
"agent": self._agent_info(),
|
||||
}
|
||||
|
||||
def _cash_payment_mirror_analyze(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Analyse cash payments entirely from the client's local ODBC Accounting Mirror.
|
||||
|
||||
This path never calls TallyPrime. Tally is required only when the user
|
||||
explicitly runs "Mirror Tally to SQLite" from the Tally Accounting page.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from datetime import date as _date
|
||||
|
||||
client_id = int(payload.get("client_id") or 0)
|
||||
if client_id <= 0:
|
||||
raise ValueError("client_id is required.")
|
||||
if not self.store.exists(client_id):
|
||||
raise ValueError("Accounting local storage is not initialized for this client.")
|
||||
|
||||
date_from = str(payload.get("date_from") or "").strip()
|
||||
date_to = str(payload.get("date_to") or "").strip()
|
||||
try:
|
||||
start_day = _date.fromisoformat(date_from)
|
||||
end_day = _date.fromisoformat(date_to)
|
||||
except Exception as exc:
|
||||
raise ValueError("Select valid From Date and To Date.") from exc
|
||||
if start_day > end_day:
|
||||
raise ValueError("From Date cannot be after To Date.")
|
||||
|
||||
cash_limit = round(float(payload.get("cash_limit") or 10000.0), 2)
|
||||
split_window_days = max(1, int(payload.get("split_window_days") or 3))
|
||||
near_limit_percent = max(1.0, min(float(payload.get("near_limit_percent") or 80.0), 100.0))
|
||||
requested_cash_ledger = str(payload.get("cash_ledger_name") or "").strip()
|
||||
|
||||
mirror_status = self.tally.mirror.status(client_id)
|
||||
mirror = mirror_status.get("mirror") or {}
|
||||
if not mirror.get("ready"):
|
||||
raise ValueError(
|
||||
"Accounting Mirror is not ready for this client. "
|
||||
"Open Tally Accounting and run 'Mirror Tally to SQLite' first."
|
||||
)
|
||||
company = mirror.get("company") or {}
|
||||
company_name = str(company.get("company_name") or "").strip()
|
||||
company_guid = str(company.get("company_guid") or "").strip()
|
||||
if not company_name and not company_guid:
|
||||
raise ValueError("Accounting Mirror does not contain a valid Tally company identity.")
|
||||
|
||||
counts = mirror.get("counts") or {}
|
||||
if int(counts.get("voucher") or 0) <= 0:
|
||||
raise ValueError(
|
||||
"The Accounting Mirror contains no vouchers. "
|
||||
"Run 'Mirror Tally to SQLite' for the required Tally period first."
|
||||
)
|
||||
|
||||
db = self.tally.mirror.open_db(client_id, company_name, company_guid)
|
||||
try:
|
||||
ledger_rows = db.execute(
|
||||
"SELECT ledger_name, parent_group FROM ledger_master ORDER BY ledger_name"
|
||||
).fetchall()
|
||||
cash_ledger_names: list[str] = []
|
||||
for row in ledger_rows:
|
||||
name = str(row["ledger_name"] or "").strip()
|
||||
parent = re.sub(r"[\s_-]+", " ", str(row["parent_group"] or "").strip()).casefold()
|
||||
if not name:
|
||||
continue
|
||||
if parent in {"cash in hand", "cash"} or name.casefold() == "cash":
|
||||
cash_ledger_names.append(name)
|
||||
|
||||
if requested_cash_ledger:
|
||||
matched = next(
|
||||
(x for x in cash_ledger_names if x.casefold() == requested_cash_ledger.casefold()),
|
||||
None,
|
||||
)
|
||||
if not matched:
|
||||
all_names = {str(row["ledger_name"] or "").strip().casefold() for row in ledger_rows}
|
||||
if requested_cash_ledger.casefold() not in all_names:
|
||||
raise ValueError(
|
||||
f"Cash ledger '{requested_cash_ledger}' is not present in the Accounting Mirror."
|
||||
)
|
||||
matched = requested_cash_ledger
|
||||
cash_ledger_names = [matched]
|
||||
|
||||
# Preserve legacy behaviour if a very old Tally company has no
|
||||
# Cash-in-Hand hierarchy metadata but contains the standard Cash ledger.
|
||||
if not cash_ledger_names:
|
||||
cash_ledger_names = [
|
||||
str(row["ledger_name"] or "").strip()
|
||||
for row in ledger_rows
|
||||
if str(row["ledger_name"] or "").strip().casefold() == "cash"
|
||||
]
|
||||
if not cash_ledger_names:
|
||||
raise ValueError(
|
||||
"No Cash / Cash-in-Hand ledger was found in the Accounting Mirror. "
|
||||
"Refresh the Tally mirror and verify the ledger hierarchy."
|
||||
)
|
||||
|
||||
placeholders = ",".join("?" for _ in cash_ledger_names)
|
||||
params = [date_from, date_to] + [x.casefold() for x in cash_ledger_names]
|
||||
|
||||
total_vouchers = int(db.execute(
|
||||
"""SELECT COUNT(*) FROM voucher
|
||||
WHERE voucher_date>=? AND voucher_date<=?
|
||||
AND COALESCE(is_cancelled,0)=0 AND COALESCE(is_optional,0)=0""",
|
||||
(date_from, date_to),
|
||||
).fetchone()[0])
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
v.voucher_guid, v.voucher_date, v.voucher_type, v.voucher_number,
|
||||
v.party_ledger, v.narration, v.reference,
|
||||
e.ledger_name, e.dr_cr, e.raw_amount, e.amount, e.raw_is_deemed_positive
|
||||
FROM voucher v
|
||||
JOIN voucher_ledger_entry e
|
||||
ON e.company_instance_id=v.company_instance_id
|
||||
AND e.voucher_guid=v.voucher_guid
|
||||
WHERE v.voucher_date>=? AND v.voucher_date<=?
|
||||
AND COALESCE(v.is_cancelled,0)=0
|
||||
AND COALESCE(v.is_optional,0)=0
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM voucher_ledger_entry ce
|
||||
WHERE ce.company_instance_id=v.company_instance_id
|
||||
AND ce.voucher_guid=v.voucher_guid
|
||||
AND LOWER(ce.ledger_name) IN ({placeholders})
|
||||
)
|
||||
ORDER BY v.voucher_date, v.voucher_type, v.voucher_number, e.line_no
|
||||
"""
|
||||
rows = db.execute(sql, params).fetchall()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
cash_keys = {x.casefold() for x in cash_ledger_names}
|
||||
grouped: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
guid = str(row["voucher_guid"] or "")
|
||||
item = grouped.setdefault(guid, {
|
||||
"date": str(row["voucher_date"] or ""),
|
||||
"voucher_type": str(row["voucher_type"] or ""),
|
||||
"voucher_number": str(row["voucher_number"] or ""),
|
||||
"party": str(row["party_ledger"] or "").strip(),
|
||||
"reference": str(row["reference"] or ""),
|
||||
"narration": str(row["narration"] or ""),
|
||||
"entries": [],
|
||||
})
|
||||
item["entries"].append({
|
||||
"ledger_name": str(row["ledger_name"] or "").strip(),
|
||||
"dr_cr": str(row["dr_cr"] or "").strip().upper(),
|
||||
"raw_amount": float(row["raw_amount"] or 0),
|
||||
"amount": float(row["amount"] or 0),
|
||||
"is_deemed_positive": str(row["raw_is_deemed_positive"] or "").strip(),
|
||||
})
|
||||
|
||||
payments: list[dict[str, Any]] = []
|
||||
for voucher in grouped.values():
|
||||
cash_credit: list[float] = []
|
||||
for entry in voucher["entries"]:
|
||||
if entry["ledger_name"].casefold() not in cash_keys:
|
||||
continue
|
||||
raw_amount = float(entry["raw_amount"] or 0)
|
||||
deemed = str(entry["is_deemed_positive"] or "").strip().lower()
|
||||
# Keep the sign interpretation used by the existing Cash Payment
|
||||
# Compliance engine, while also respecting the mirror's DR/CR field.
|
||||
if entry["dr_cr"] == "CR" or raw_amount > 0 or deemed == "no":
|
||||
cash_credit.append(abs(raw_amount if raw_amount else float(entry["amount"] or 0)))
|
||||
cash_amount = round(sum(cash_credit), 2)
|
||||
if cash_amount <= 0:
|
||||
continue
|
||||
|
||||
non_cash = [e for e in voucher["entries"] if e["ledger_name"].casefold() not in cash_keys]
|
||||
party = str(voucher["party"] or "").strip()
|
||||
if not party or party.casefold() in cash_keys:
|
||||
candidates = sorted(
|
||||
non_cash,
|
||||
key=lambda e: abs(float(e["raw_amount"] or e["amount"] or 0)),
|
||||
reverse=True,
|
||||
)
|
||||
party = candidates[0]["ledger_name"] if candidates else "Unidentified counter-ledger"
|
||||
|
||||
payments.append({
|
||||
"date": voucher["date"],
|
||||
"party": party or "Unidentified counter-ledger",
|
||||
"amount": cash_amount,
|
||||
"voucher_number": voucher["voucher_number"],
|
||||
"voucher_type": voucher["voucher_type"],
|
||||
"reference": voucher["reference"],
|
||||
"narration": voucher["narration"],
|
||||
})
|
||||
|
||||
by_party_date = defaultdict(list)
|
||||
for item in payments:
|
||||
by_party_date[(item["party"].casefold(), item["date"])].append(item)
|
||||
|
||||
exceptions: list[dict[str, Any]] = []
|
||||
seen = set()
|
||||
single_count = 0
|
||||
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), party_rows in by_party_date.items():
|
||||
total = round(sum(x["amount"] for x in party_rows), 2)
|
||||
if total > cash_limit + 0.009 and len(party_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": 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 party_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: list[dict[str, Any]] = []
|
||||
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:
|
||||
continue
|
||||
if 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": company_name,
|
||||
"company_guid": company_guid,
|
||||
"date_from": date_from,
|
||||
"date_to": date_to,
|
||||
"cash_limit": cash_limit,
|
||||
"cash_ledgers": cash_ledger_names,
|
||||
"summary": {
|
||||
"vouchers_reviewed": total_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,
|
||||
"sqlite_mirror": True,
|
||||
"sqlite_cache": False,
|
||||
"mirror_db_path": str(mirror_status.get("mirror_db_path") or mirror.get("path") or ""),
|
||||
"mirror_period": mirror.get("voucher_period") or {},
|
||||
"tally_contacted": False,
|
||||
"tally_request_count": 0,
|
||||
"tally_retry_count": 0,
|
||||
"tally_pause_ms": 0,
|
||||
"read_only": True,
|
||||
},
|
||||
"mirror": mirror,
|
||||
"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
|
||||
|
||||
Reference in New Issue
Block a user