Switch GST reconciliation to direct browser and manual return import

This commit is contained in:
A R R R Associates
2026-09-10 22:27:15 +05:30
parent 0ce2954674
commit 3b3b9c5ebe
2 changed files with 276 additions and 57 deletions
+232 -1
View File
@@ -4,6 +4,9 @@ import calendar
import re
import tempfile
import uuid
import io
import json
import zipfile
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlencode
@@ -24,6 +27,7 @@ from app.modules.credential_vault.crypto import decrypt_value
from app.modules.credential_vault.models import CredentialVaultEntry
from app.modules.credential_vault.service import can_view_entry, log_access
from app.modules.documents.services import client_folder_parts, get_active_storage_node_for_branch, sanitize_segment
from app.modules.documents.local_agent_runtime.erp_local_agent.gst_portal_runtime import extract_invoice_rows, extract_gstr3b_itc
from app.modules.registrations.models import ClientRegistration, RegistrationType
router = APIRouter(prefix="/tools/accounting/gst-reconciliation", tags=["accounting-gst-reconciliation-ui"])
@@ -170,6 +174,103 @@ def _selected_periods(download_mode: str, financial_year: str, period: str) -> l
return [digits]
GST_LOGIN_URL = "https://services.gst.gov.in/services/login"
_MAX_IMPORT_BYTES = 250 * 1024 * 1024
def _safe_upload_name(name: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._ -]+", "_", str(name or "").strip()).strip(" ._")
return cleaned or "return.json"
def _period_from_name_or_payload(name: str, payload, fallback: str = "") -> str:
def valid(v: str) -> str:
d = re.sub(r"\D", "", str(v or ""))
if len(d) == 6:
mm, yy = int(d[:2]), int(d[2:])
if 1 <= mm <= 12 and 2000 <= yy <= 2100:
return d
yy2, mm2 = int(d[:4]), int(d[4:])
if 2000 <= yy2 <= 2100 and 1 <= mm2 <= 12:
return f"{mm2:02d}{yy2:04d}"
return ""
if isinstance(payload, dict):
stack = [payload]
seen = 0
while stack and seen < 5000:
obj = stack.pop(); seen += 1
if not isinstance(obj, dict):
continue
for key in ("fp", "rtn_prd", "return_period", "period", "ret_period"):
if key in obj:
found = valid(obj.get(key))
if found:
return found
for value in obj.values():
if isinstance(value, dict):
stack.append(value)
elif isinstance(value, list):
stack.extend(x for x in value[:200] if isinstance(x, dict))
for pattern in (r"(?<!\d)(0[1-9]|1[0-2])(20\d{2})(?!\d)", r"(?<!\d)(20\d{2})(0[1-9]|1[0-2])(?!\d)"):
m = re.search(pattern, str(name or ""))
if m:
if len(m.group(1)) == 2:
return f"{m.group(1)}{m.group(2)}"
return f"{m.group(2)}{m.group(1)}"
return valid(fallback)
def _json_members(upload_name: str, content: bytes) -> list[tuple[str, object, bytes]]:
lower = str(upload_name or "").lower()
if lower.endswith(".json"):
try:
payload = json.loads(content.decode("utf-8-sig", errors="strict"))
except Exception as exc:
raise ValueError(f"{upload_name}: invalid JSON file ({exc}).") from exc
return [(upload_name, payload, content)]
if lower.endswith(".zip"):
out = []
try:
with zipfile.ZipFile(io.BytesIO(content), "r") as archive:
for info in archive.infolist():
if info.is_dir() or not info.filename.lower().endswith(".json"):
continue
raw = archive.read(info)
try:
payload = json.loads(raw.decode("utf-8-sig", errors="strict"))
except Exception:
continue
out.append((Path(info.filename).name, payload, raw))
except zipfile.BadZipFile as exc:
raise ValueError(f"{upload_name}: invalid ZIP file.") from exc
if not out:
raise ValueError(f"{upload_name}: ZIP does not contain readable JSON return data.")
return out
raise ValueError(f"{upload_name}: only GST JSON or ZIP files are supported.")
def _merge_invoice_rows(payloads: list[object]) -> list[dict]:
rows=[]; seen=set()
for payload in payloads:
for row in extract_invoice_rows(payload):
key=(str(row.get("gstin") or ""), str(row.get("invoice_key") or ""), str(row.get("invoice_date") or ""))
if key in seen:
continue
seen.add(key); rows.append(row)
return rows
def _merge_gstr3b_itc(payloads: list[object]) -> dict[str, float]:
totals={"igst":0.0,"cgst":0.0,"sgst":0.0,"cess":0.0}
for payload in payloads:
row=extract_gstr3b_itc(payload)
for k in totals:
totals[k]=round(totals[k]+float(row.get(k) or 0),2)
return totals
@router.get("")
def page(request: Request, client_id: int | None = None, registration_id: int | None = None, period: str = "", financial_year: str = "", download_mode: str = "single", message: str = "", error: str = ""):
db = CommonSessionLocal()
@@ -196,7 +297,7 @@ def page(request: Request, client_id: int | None = None, registration_id: int |
return templates.TemplateResponse("modules/accounting/templates/accounting/gst_reconciliation.html",{
"request":request,"current_user":user,"current_user_roles":get_user_roles(db,user.id),"current_user_permissions":get_user_permissions(db,user.id),"csrf_token":get_or_create_csrf_token(request),
"clients":clients,"selected_client":selected,"registrations":registrations,"selected_registration":selected_reg,"credentials":credentials,"node":node,"node_online":_node_online(node) if node else False,
"period":period,"financial_year":financial_year,"download_mode":download_mode,"operator_job":operator_job,"message":message,"error":error,"title":"GST Return Reconciliation",
"period":period,"financial_year":financial_year,"download_mode":download_mode,"operator_job":operator_job,"gst_login_url":GST_LOGIN_URL,"message":message,"error":error,"title":"GST Return Reconciliation",
})
finally:
db.close()
@@ -318,6 +419,136 @@ def operator_package(job_id: str, token: str):
return JSONResponse({"ok":False,"error":str(exc)},status_code=400)
@router.post("/import")
async def import_downloaded_returns(
request: Request,
client_id: int = Form(...), registration_id: int = Form(...), period: str = Form(""),
financial_year: str = Form(...), download_mode: str = Form("single"),
gstr1_files: list[UploadFile] = File(default=[]),
gstr2b_files: list[UploadFile] = File(default=[]),
gstr3b_files: list[UploadFile] = File(default=[]),
gstr2a_files: list[UploadFile] = File(default=[]),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
temp_root = None
package_path = None
try:
user, response = _require_partner(request, db, "accounting.learning.manage")
if response:
return response
client, _, scope = _find_visible_client(db, request, user, client_id)
if not client:
return _redirect(client_id, error="Client is not available in your scope.")
pair = next(((r,t) for r,t in _gst_regs(db, scope.tenant_id, client.id) if int(r.id)==registration_id and _is_gstin(r,t)), None)
if not pair:
return _redirect(client_id, error="Select a valid GSTIN registration.")
reg, _ = pair
gstin = _norm_gstin(reg.registration_number)
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
if not node or not _node_online(node):
return _redirect(client_id, registration_id=registration_id, period=period, financial_year=financial_year, download_mode=download_mode, error="Configured Local Storage Agent is offline.")
allowed_periods = set(_selected_periods(download_mode, financial_year, period))
if download_mode == "single":
financial_year = _fy_for_period(next(iter(allowed_periods)))
accounting_dir, gst_dir = _storage_payload(client, financial_year, gstin)
groups = {
"GSTR1": gstr1_files or [], "GSTR2B": gstr2b_files or [],
"GSTR3B": gstr3b_files or [], "GSTR2A": gstr2a_files or [],
}
if not any(groups.values()):
raise ValueError("Select at least one GST JSON/ZIP file to import.")
temp_root = Path(tempfile.mkdtemp(prefix="gst_manual_import_"))
by_period: dict[str, dict[str, dict]] = {}
total_bytes = 0
imported_files = 0
for rtype, uploads in groups.items():
for upload in uploads:
if not upload or not upload.filename:
continue
content = await upload.read()
total_bytes += len(content)
if total_bytes > _MAX_IMPORT_BYTES:
raise ValueError("GST import exceeds the 250 MB safety limit.")
for member_name, payload, raw in _json_members(upload.filename, content):
p = _period_from_name_or_payload(member_name, payload, period if download_mode == "single" else "")
if not p:
raise ValueError(f"Could not determine return period for {member_name}. Use GST JSON containing fp/return period, or Single Month mode.")
if p not in allowed_periods:
raise ValueError(f"{member_name} belongs to {p}, outside the selected {'financial year' if download_mode=='full_fy' else 'month'}.")
slot = by_period.setdefault(p, {}).setdefault(rtype, {"payloads": [], "raw": []})
slot["payloads"].append(payload)
slot["raw"].append((member_name, raw))
imported_files += 1
if not by_period:
raise ValueError("No readable GST JSON return data was found in the selected files.")
now = datetime.now(timezone.utc).isoformat()
return_types = set()
for p, period_data in sorted(by_period.items()):
base = temp_root / p
raw_dir = base / "raw"
normalized_dir = base / "normalized"
raw_dir.mkdir(parents=True, exist_ok=True)
normalized_dir.mkdir(parents=True, exist_ok=True)
manifest_downloaded=[]; normalized={}
for rtype, data in period_data.items():
return_types.add(rtype)
originals = raw_dir / "original"
originals.mkdir(parents=True, exist_ok=True)
payloads = data["payloads"]
for idx, (member_name, raw) in enumerate(data["raw"], 1):
safe = _safe_upload_name(member_name)
dest = originals / f"{rtype}_{idx:03d}_{safe}"
dest.write_bytes(raw)
manifest_downloaded.append({"return_type": rtype, "source_name": member_name, "path": str(dest.relative_to(temp_root).as_posix()), "bytes": len(raw)})
canonical = payloads[0] if len(payloads) == 1 else {"period": p, "source_files": len(payloads), "payloads": payloads}
(raw_dir / f"{p}_{rtype}.json").write_text(json.dumps(canonical, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
if rtype in {"GSTR1", "GSTR2A", "GSTR2B"}:
rows = _merge_invoice_rows(payloads)
(normalized_dir / f"{p}_{rtype}_invoices.json").write_text(json.dumps({"period":p,"rows":rows}, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
normalized[rtype] = {"invoice_rows": len(rows)}
elif rtype == "GSTR3B":
itc = _merge_gstr3b_itc(payloads)
(normalized_dir / f"{p}_{rtype}_itc.json").write_text(json.dumps({"period":p,"itc":itc}, ensure_ascii=False, indent=2), encoding="utf-8")
normalized[rtype] = {"itc": itc}
(base / "download_manifest.json").write_text(json.dumps({"gstin":gstin,"period":p,"financial_year":financial_year,"downloaded_at_utc":now,"source":"manual_gst_portal_browser_import","downloaded":manifest_downloaded,"normalized":normalized}, ensure_ascii=False, indent=2), encoding="utf-8")
jti = uuid.uuid4().hex
token = _encode_operator_token({"jti":jti,"user_id":int(user.id),"tenant_id":int(scope.tenant_id),"branch_id":int(scope.branch_id),"node_code":str(node.node_code),"client_id":int(client.id),"gstin":gstin,"financial_year":financial_year})
_UPLOAD_ROOT.mkdir(parents=True, exist_ok=True)
package_path = _UPLOAD_ROOT / f"{jti}.zip"
with zipfile.ZipFile(package_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
for f in sorted(temp_root.rglob("*")):
if f.is_file():
archive.write(f, f.relative_to(temp_root).as_posix())
package_url = str(get_settings().ERP_PUBLIC_BASE_URL).rstrip("/") + f"/tools/accounting/gst-reconciliation/operator/package/{jti}?token={token}"
result = request_agent_command(str(node.node_code), "gst_return_package_store", {
"client_id": int(client.id), "gstin": gstin, "financial_year": financial_year,
"gst_relative_dir": gst_dir, "package_url": package_url,
"periods": sorted(by_period), "return_types": sorted(return_types),
}, timeout_seconds=180)
if not result.get("ok"):
raise RuntimeError(result.get("error") or "Local Storage Agent could not store the imported GST return package.")
return _redirect(client_id, registration_id=registration_id, period=period, financial_year=financial_year, download_mode=download_mode, message=f"Imported {imported_files} GST JSON file(s) for {len(by_period)} period(s) and stored them in the client GST directory. You can now run reconciliation.")
except Exception as exc:
db.rollback()
return _redirect(client_id, registration_id=registration_id, period=period, financial_year=financial_year, download_mode=download_mode, error=str(exc))
finally:
db.close()
if package_path:
try: package_path.unlink(missing_ok=True)
except Exception: pass
if temp_root:
import shutil
shutil.rmtree(temp_root, ignore_errors=True)
@router.post("/analyze")
def analyze(request: Request, client_id: int=Form(...), registration_id: int=Form(...), period: str=Form(""), financial_year: str=Form(...), analyze_mode: str=Form("single"), csrf_token: str=Form(...)):
validate_csrf(request,csrf_token)
@@ -2,7 +2,7 @@
{% 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">Download GST returns on the workstation where you are using ERP, store them in 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">Open GST Portal directly in your browser, import downloaded return files into 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 %}
@@ -19,37 +19,53 @@
{% if selected_client and selected_registration %}
<div class="grid gap-4 lg:grid-cols-2">
<form method="post" action="/tools/accounting/gst-reconciliation/download/start" 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 }}"><input type="hidden" name="download_mode" value="{{ download_mode }}">
<h2 class="font-semibold">1. Download from GST Portal</h2>
<p class="text-xs text-slate-500">The visible GST browser opens on <b>this computer</b> through the ERP Local Agent at 127.0.0.1:8788. CAPTCHA/OTP remains visible here. After download, return data is transferred to the configured Local Storage Agent and stored under the client's FY/GST/GSTIN/period directory.</p>
<label class="text-sm">Credential Vault Entry<select name="credential_id" required class="mt-1 w-full rounded border p-2"><option value="">Select GST credential</option>{% 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 %}</select></label>
{% if credentials|length==1 %}<div class="text-xs text-slate-500">The only eligible GST Portal credential has been selected automatically.</div>{% endif %}
<div class="rounded-lg border p-3">
<div class="mb-2 text-sm font-medium">Returns to download</div>
<div class="grid gap-2 sm:grid-cols-2"><label class="flex gap-2 text-sm"><input type="checkbox" name="gstr1" value="1" checked> GSTR-1</label><label class="flex gap-2 text-sm"><input type="checkbox" name="gstr2b" value="1" checked> GSTR-2B</label><label class="flex gap-2 text-sm"><input type="checkbox" name="gstr3b" value="1" checked> GSTR-3B</label><label class="flex gap-2 text-sm"><input type="checkbox" name="gstr2a" value="1"> GSTR-2A</label></div>
<div class="rounded-xl border bg-white p-4 space-y-4">
<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>
</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>
<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>
</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.
</div>
</div>
<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 }}">
<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">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>
</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">The operator workstation also needs ERP Local Agent 1.26.15+ running so the browser can open here.</div>
<div class="text-xs text-slate-500">No Local Agent is required on the computer where this browser is open.</div>
</form>
</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">2. 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 stored local return data after download.</p>
<label class="text-sm">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>
<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>
<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>
</form>
</div>
<div id="operatorStatus" class="hidden rounded-xl border bg-white p-4">
<div class="flex items-center justify-between"><h2 class="font-semibold">Interactive GST Download</h2><span id="operatorPct" class="text-sm font-semibold"></span></div>
<div class="mt-2 h-2 overflow-hidden rounded bg-slate-100"><div id="operatorBar" class="h-2 bg-indigo-600" style="width:0%"></div></div>
<div id="operatorMessage" class="mt-3 text-sm text-slate-700"></div>
<div id="operatorDetail" class="mt-1 text-xs text-slate-500"></div>
</div>
</div>
</form>
{% set a=request.session.get('gst_reconciliation_result') or {} %}
{% if a %}
@@ -62,33 +78,5 @@
{% endif %}
</div>
{% if operator_job %}
<script>
(function(){
const token={{ operator_job.token|tojson }};
const jobId={{ operator_job.job_id|tojson }};
const box=document.getElementById('operatorStatus'), msg=document.getElementById('operatorMessage'), detail=document.getElementById('operatorDetail'), bar=document.getElementById('operatorBar'), pct=document.getElementById('operatorPct');
box.classList.remove('hidden');
function show(text,extra,p){msg.textContent=text||'';detail.textContent=extra||''; if(p!==undefined){const n=Math.max(0,Math.min(100,Number(p)||0));bar.style.width=n+'%';pct.textContent=Math.round(n)+'%';}}
async function poll(){
try{
const r=await fetch('http://127.0.0.1:8788/api/gst/operator/status?job_id='+encodeURIComponent(jobId),{cache:'no-store'}); const d=await r.json(); const j=d.job||{};
let p=0; if(j.period_total){p=Math.round(((Number(j.period_index||1)-1)/Number(j.period_total))*90)+5;} if(j.status==='completed'||j.status==='failed')p=100;
show(j.message||j.stage||j.status||'Working…',j.period?('Current period: '+j.period):'',p);
if(j.status==='completed'){detail.textContent='Completed. Return data has been transferred to the configured client local storage.';return;}
if(j.status==='failed'){msg.className='mt-3 text-sm text-red-700';return;}
}catch(e){show('Waiting for ERP Local Agent on this computer…','Ensure ERP Local Agent 1.26.15+ is running at http://127.0.0.1:8788.',2);}
setTimeout(poll,1500);
}
async function start(){
show('Connecting to ERP Local Agent on this computer…','The visible GST browser will open here.',2);
try{
const r=await fetch('http://127.0.0.1:8788/api/gst/operator/start',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:token})}); const d=await r.json(); if(!r.ok||!d.ok)throw new Error(d.error||'Could not start local GST browser.');
poll();
}catch(e){msg.className='mt-3 text-sm text-red-700';show('Could not start GST browser on this computer.',e.message+' Ensure ERP Local Agent 1.26.15+ is running locally.',100);}
}
start();
})();
</script>
{% endif %}
{% endblock %}