Add lightweight GST operator agent for portal automation

This commit is contained in:
A R R R Associates
2026-09-11 11:15:12 +05:30
parent 2ad8e44312
commit 3e660ec0b9
7 changed files with 640 additions and 26 deletions
@@ -2,39 +2,89 @@
{% block content %}
<div class="mx-auto max-w-7xl space-y-5 p-4">
<div class="flex items-center justify-between gap-3">
<div><h1 class="text-2xl font-bold">GST Return Reconciliation</h1><p class="text-sm text-slate-600">Open GST Portal directly in your browser, import downloaded return files into the configured client local storage, and reconcile against Accounting Mirror.</p></div>
<div>
<h1 class="text-2xl font-bold">GST Return Reconciliation</h1>
<p class="text-sm text-slate-600">Download GST returns through the lightweight operator agent, store them in the configured client local storage, and reconcile against Accounting Mirror.</p>
</div>
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-lg border px-3 py-2 text-sm">Back to Accounting</a>
</div>
{% if message %}<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-emerald-800">{{ message }}</div>{% endif %}
{% if error %}<div class="rounded-lg border border-red-200 bg-red-50 p-3 text-red-800">{{ error }}</div>{% endif %}
<form method="get" class="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-5">
<label class="text-sm">Client<select name="client_id" class="mt-1 w-full rounded border p-2" onchange="this.form.submit()"><option value="">Select client</option>{% for c in clients %}<option value="{{ c.id }}" {% if selected_client and c.id==selected_client.id %}selected{% endif %}>{{ c.client_name }}</option>{% endfor %}</select></label>
<label class="text-sm">GSTIN<select name="registration_id" class="mt-1 w-full rounded border p-2" onchange="this.form.submit()"><option value="">Select GSTIN</option>{% for r,t in registrations %}<option value="{{ r.id }}" {% if selected_registration and r.id==selected_registration.id %}selected{% endif %}>{{ r.registration_number }}{% if r.trade_name %} — {{ r.trade_name }}{% endif %}</option>{% endfor %}</select></label>
<label class="text-sm">Client
<select name="client_id" class="mt-1 w-full rounded border p-2" onchange="this.form.submit()">
<option value="">Select client</option>
{% for c in clients %}<option value="{{ c.id }}" {% if selected_client and c.id==selected_client.id %}selected{% endif %}>{{ c.client_name }}</option>{% endfor %}
</select>
</label>
<label class="text-sm">GSTIN
<select name="registration_id" class="mt-1 w-full rounded border p-2" onchange="this.form.submit()">
<option value="">Select GSTIN</option>
{% for r,t in registrations %}<option value="{{ r.id }}" {% if selected_registration and r.id==selected_registration.id %}selected{% endif %}>{{ r.registration_number }}{% if r.trade_name %} — {{ r.trade_name }}{% endif %}</option>{% endfor %}
</select>
</label>
<label class="text-sm">Financial Year<input name="financial_year" value="{{ financial_year }}" pattern="[0-9]{4}-[0-9]{2}" placeholder="2025-26" class="mt-1 w-full rounded border p-2"></label>
<label class="text-sm">Mode<select name="download_mode" class="mt-1 w-full rounded border p-2"><option value="single" {% if download_mode=='single' %}selected{% endif %}>Single Month</option><option value="full_fy" {% if download_mode=='full_fy' %}selected{% endif %}>Full Financial Year</option></select></label>
<label class="text-sm">Mode
<select name="download_mode" class="mt-1 w-full rounded border p-2">
<option value="single" {% if download_mode=='single' %}selected{% endif %}>Single Month</option>
<option value="full_fy" {% if download_mode=='full_fy' %}selected{% endif %}>Full Financial Year</option>
</select>
</label>
<div class="flex items-end"><button class="w-full rounded bg-slate-800 px-3 py-2 text-white">Load</button></div>
<label class="text-sm md:col-span-2">Single Month Period (MMYYYY)<input name="period" value="{{ period }}" pattern="[0-9]{6}" placeholder="042025" class="mt-1 w-full rounded border p-2"><span class="text-xs text-slate-500">Used only when Mode = Single Month.</span></label>
<label class="text-sm md:col-span-2">Single Month Period (MMYYYY)
<input name="period" value="{{ period }}" pattern="[0-9]{6}" placeholder="042025" class="mt-1 w-full rounded border p-2">
<span class="text-xs text-slate-500">Used only when Mode = Single Month.</span>
</label>
</form>
{% if selected_client and selected_registration %}
<div class="grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border bg-white p-4 space-y-4">
<form method="post" action="/tools/accounting/gst-reconciliation/download/start" class="rounded-xl border bg-white p-4 space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="registration_id" value="{{ selected_registration.id }}">
<input type="hidden" name="period" value="{{ period }}">
<input type="hidden" name="financial_year" value="{{ financial_year }}">
<input type="hidden" name="download_mode" value="{{ download_mode }}">
<div>
<h2 class="font-semibold">1. Download from GST Portal</h2>
<p class="mt-1 text-sm text-slate-600">Open the official GST Portal directly in this browser. No ERP Local Agent is required on this computer. Complete login, CAPTCHA and OTP normally, then download the required return JSON/ZIP files.</p>
<h2 class="font-semibold">1. Automatic GST Download</h2>
<p class="mt-1 text-sm text-slate-600">The lightweight GST Operator Agent runs only on this workstation. It opens visible Chrome/Edge, autofills the selected Credential Vault username/password, and waits for you to complete CAPTCHA/OTP. It does not run Tally or hold the client storage.</p>
</div>
<div class="flex flex-wrap items-center gap-2 rounded-lg border bg-slate-50 p-3 text-sm">
<span>GST Operator Agent:</span>
<span id="gst-agent-status" class="font-semibold text-slate-600">Checking…</span>
<span class="text-slate-400">v{{ operator_agent_version }}</span>
<a href="/tools/accounting/gst-reconciliation/operator-agent/download" class="ml-auto rounded border bg-white px-3 py-1.5 text-xs font-medium">Download / Install Agent</a>
</div>
<label class="text-sm">Credential Vault Entry
<select class="mt-1 w-full rounded border p-2" disabled>
{% if credentials %}{% for c in credentials %}<option {% if credentials|length==1 %}selected{% endif %}>{{ c.title }}{% if c.reference_number %} — {{ c.reference_number }}{% endif %}</option>{% endfor %}{% else %}<option>No GST Portal credential found</option>{% endif %}
<select name="credential_id" required class="mt-1 w-full rounded border p-2" {% if not credentials %}disabled{% endif %}>
{% if credentials %}
{% for c in credentials %}<option value="{{ c.id }}" {% if credentials|length==1 %}selected{% endif %}>{{ c.title }}{% if c.reference_number %} — {{ c.reference_number }}{% endif %}</option>{% endfor %}
{% else %}
<option>No GST Portal credential found</option>
{% endif %}
</select>
</label>
<p class="text-xs text-slate-500">Credential Vault remains linked to this GSTIN for reference and future API-based workflows. The browser login is manual because the GST Portal is on a different website and ERP cannot read or autofill that page across browser security boundaries.</p>
<a href="{{ gst_login_url }}" target="_blank" rel="noopener noreferrer" class="inline-flex rounded bg-indigo-600 px-4 py-2 text-white">Open GST Portal</a>
<div class="rounded-lg border bg-slate-50 p-3 text-xs text-slate-600">
<b>For Full Financial Year:</b> download the selected GSTR-1, GSTR-2B and GSTR-3B JSON/ZIP files for all required months. You can select multiple files below in one import.
{% if credentials|length==1 %}<p class="text-xs text-slate-500">The only eligible GST Portal credential has been selected automatically.</p>{% endif %}
<div class="rounded-lg border p-3">
<div class="mb-2 text-sm font-medium">Returns to download</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<label><input type="checkbox" name="gstr1" value="1" checked class="mr-2">GSTR-1</label>
<label><input type="checkbox" name="gstr2b" value="1" checked class="mr-2">GSTR-2B</label>
<label><input type="checkbox" name="gstr3b" value="1" checked class="mr-2">GSTR-3B</label>
<label><input type="checkbox" name="gstr2a" value="1" class="mr-2">GSTR-2A</label>
</div>
</div>
</div>
<button {% if not node_online or not credentials %}disabled{% endif %} class="rounded bg-indigo-600 px-4 py-2 text-white disabled:opacity-50">Start GST Download</button>
<div class="text-xs">Configured Storage Agent: <b>{{ 'Online' if node_online else 'Offline' }}</b></div>
<p class="text-xs text-slate-500">For Full Financial Year, the job processes April through March and reports progress month by month. The password is redeemed directly by the localhost agent and is never placed in this page's HTML or URL.</p>
</form>
<form method="post" action="/tools/accounting/gst-reconciliation/import" enctype="multipart/form-data" class="rounded-xl border bg-white p-4 space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
@@ -44,23 +94,28 @@
<input type="hidden" name="financial_year" value="{{ financial_year }}">
<input type="hidden" name="download_mode" value="{{ download_mode }}">
<div>
<h2 class="font-semibold">2. Import Downloaded Returns to Client Storage</h2>
<p class="mt-1 text-sm text-slate-600">Select the JSON or ZIP files downloaded from GST Portal. ERP identifies each return period, normalizes the data, and transfers it to the configured Local Storage Agent under the client's FY/GST/GSTIN/period directory.</p>
<h2 class="font-semibold">2. Manual Import Fallback</h2>
<p class="mt-1 text-sm text-slate-600">If a GST portal download cannot be automated, keep the workflow moving by importing the portal JSON/ZIP files. They use the same client GST storage hierarchy and the same reconciliation engine.</p>
</div>
<label class="block text-sm">GSTR-1 files<input type="file" name="gstr1_files" accept=".json,.zip,application/json,application/zip" multiple class="mt-1 block w-full rounded border p-2"></label>
<label class="block text-sm">GSTR-2B files<input type="file" name="gstr2b_files" accept=".json,.zip,application/json,application/zip" multiple class="mt-1 block w-full rounded border p-2"></label>
<label class="block text-sm">GSTR-3B files<input type="file" name="gstr3b_files" accept=".json,.zip,application/json,application/zip" multiple class="mt-1 block w-full rounded border p-2"></label>
<label class="block text-sm">GSTR-2A files <span class="text-slate-400">(optional)</span><input type="file" name="gstr2a_files" accept=".json,.zip,application/json,application/zip" multiple class="mt-1 block w-full rounded border p-2"></label>
<button {% if not node_online %}disabled{% endif %} class="rounded bg-sky-700 px-4 py-2 text-white disabled:opacity-50">Import & Store GST Returns</button>
<div class="text-xs">Configured Storage Agent: <b>{{ 'Online' if node_online else 'Offline' }}</b></div>
<div class="text-xs text-slate-500">No Local Agent is required on the computer where this browser is open.</div>
</form>
</div>
<div id="gst-job-panel" class="hidden rounded-xl border bg-white p-4 space-y-2">
<div class="flex items-center justify-between"><h2 class="font-semibold">Interactive GST Download</h2><span id="gst-job-percent" class="text-sm font-medium">0%</span></div>
<div class="h-2 overflow-hidden rounded bg-slate-200"><div id="gst-job-bar" class="h-full bg-indigo-600" style="width:0%"></div></div>
<div id="gst-job-stage" class="text-sm font-medium"></div>
<div id="gst-job-message" class="text-sm text-slate-600"></div>
</div>
<form method="post" action="/tools/accounting/gst-reconciliation/analyze" class="rounded-xl border bg-white p-4 space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="registration_id" value="{{ selected_registration.id }}"><input type="hidden" name="period" value="{{ period }}"><input type="hidden" name="financial_year" value="{{ financial_year }}">
<h2 class="font-semibold">3. Reconcile Stored Data</h2>
<p class="text-sm text-slate-600">Sales: Accounting Mirror vs GSTR-1. Purchases: Accounting Mirror vs GSTR-2B. ITC: GSTR-2B vs GSTR-3B. Analysis reads only return data already stored in the client's local-storage GST directory.</p>
<p class="text-sm text-slate-600">Sales: Accounting Mirror vs GSTR-1. Purchases: Accounting Mirror vs GSTR-2B. ITC: GSTR-2B vs GSTR-3B. Analysis reads only stored client data, so download and reconciliation remain independent.</p>
<div class="flex flex-wrap items-end gap-3">
<label class="text-sm min-w-64">Analysis scope<select name="analyze_mode" class="mt-1 w-full rounded border p-2"><option value="single">Single Month</option><option value="full_fy" {% if download_mode=='full_fy' %}selected{% endif %}>Full Financial Year</option></select></label>
<button {% if not node_online %}disabled{% endif %} class="rounded bg-emerald-600 px-4 py-2 text-white disabled:opacity-50">Run Reconciliation</button>
@@ -78,5 +133,80 @@
{% endif %}
</div>
<script>
(() => {
const port = {{ operator_agent_port|int }};
const base = `http://127.0.0.1:${port}`;
const statusEl = document.getElementById('gst-agent-status');
const panel = document.getElementById('gst-job-panel');
const bar = document.getElementById('gst-job-bar');
const pct = document.getElementById('gst-job-percent');
const stage = document.getElementById('gst-job-stage');
const msg = document.getElementById('gst-job-message');
async function agentStatus() {
if (!statusEl) return false;
try {
const r = await fetch(base + '/api/status', {cache:'no-store'});
const b = await r.json();
if (!r.ok || !b.ok) throw new Error(b.error || 'Agent unavailable');
statusEl.textContent = `Online · v${b.version}`;
statusEl.className = 'font-semibold text-emerald-700';
return true;
} catch (e) {
statusEl.textContent = 'Not installed / not running';
statusEl.className = 'font-semibold text-red-600';
return false;
}
}
function renderJob(job) {
panel?.classList.remove('hidden');
const total = Number(job.period_total || (job.periods || []).length || 1);
const index = Number(job.period_index || 0);
let percent = Number(job.percent || 0);
if (!percent && total) percent = Math.min(95, Math.round((index / total) * 100));
if (job.status === 'completed') percent = 100;
if (bar) bar.style.width = `${percent}%`;
if (pct) pct.textContent = `${percent}%`;
if (stage) stage.textContent = job.stage || job.status || '';
if (msg) msg.textContent = job.message || job.error || '';
}
async function poll(jobId) {
try {
const r = await fetch(base + '/api/gst/status?job_id=' + encodeURIComponent(jobId), {cache:'no-store'});
const b = await r.json();
if (b.job) renderJob(b.job);
if (b.job && !['completed','failed'].includes(b.job.status)) setTimeout(() => poll(jobId), 1800);
} catch (e) {
if (msg) msg.textContent = 'Could not read GST Operator Agent progress: ' + e.message;
}
}
agentStatus();
{% if operator_job %}
const prepared = {{ operator_job|tojson }};
(async () => {
panel?.classList.remove('hidden');
if (stage) stage.textContent = 'Connecting to GST Operator Agent';
if (msg) msg.textContent = 'Starting visible GST login on this workstation…';
try {
const online = await agentStatus();
if (!online) throw new Error('Install/start the GST Operator Agent using the button above, then retry Start GST Download.');
const r = await fetch(base + '/api/gst/start', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({token:prepared.token})
});
const b = await r.json();
if (!r.ok || !b.ok) throw new Error(b.error || 'Agent could not start GST browser.');
renderJob(b.job || {status:'queued',stage:'Queued',message:'GST browser job queued.'});
poll(prepared.job_id);
} catch (e) {
renderJob({status:'failed',percent:100,stage:'Could not start GST browser',message:e.message});
}
})();
{% endif %}
})();
</script>
{% endblock %}