Add GST return download local storage and reconciliation workflow
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import re
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
||||
from app.core.templating import templates
|
||||
from app.modules.accounting.agent_bridge import request_agent_command
|
||||
from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients, _node_online
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
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 build_document_scope, client_folder_parts, get_active_storage_node_for_branch, sanitize_segment
|
||||
from app.modules.registrations.models import ClientRegistration, RegistrationType
|
||||
|
||||
router = APIRouter(prefix="/tools/accounting/gst-reconciliation", tags=["accounting-gst-reconciliation-ui"])
|
||||
|
||||
|
||||
def _fy_bounds(fy: str) -> tuple[date, date]:
|
||||
m = re.fullmatch(r"(\d{4})-(\d{2})", str(fy or "").strip())
|
||||
if not m:
|
||||
raise ValueError("Invalid financial year.")
|
||||
y = int(m.group(1))
|
||||
return date(y, 4, 1), date(y + 1, 3, 31)
|
||||
|
||||
|
||||
def _fy_for_period(period: str) -> str:
|
||||
digits = re.sub(r"\D", "", period or "")
|
||||
if len(digits) != 6:
|
||||
raise ValueError("Return period must be MMYYYY.")
|
||||
month, year = int(digits[:2]), int(digits[2:])
|
||||
if month < 1 or month > 12:
|
||||
raise ValueError("Invalid GST return month.")
|
||||
sy = year if month >= 4 else year - 1
|
||||
return f"{sy}-{str(sy+1)[-2:]}"
|
||||
|
||||
|
||||
def _period_bounds(period: str) -> tuple[str, str]:
|
||||
digits = re.sub(r"\D", "", period or "")
|
||||
month, year = int(digits[:2]), int(digits[2:])
|
||||
last = calendar.monthrange(year, month)[1]
|
||||
return date(year, month, 1).isoformat(), date(year, month, last).isoformat()
|
||||
|
||||
|
||||
def _gst_regs(db, tenant_id: int, client_id: int):
|
||||
return db.execute(
|
||||
select(ClientRegistration, RegistrationType)
|
||||
.join(RegistrationType, RegistrationType.id == ClientRegistration.registration_type_id)
|
||||
.where(ClientRegistration.tenant_id == tenant_id, ClientRegistration.client_id == client_id)
|
||||
.order_by(ClientRegistration.id.asc())
|
||||
).all()
|
||||
|
||||
|
||||
def _is_gstin(reg, typ) -> bool:
|
||||
code = str(getattr(typ, "code", "") or "").upper().strip()
|
||||
num = re.sub(r"\s+", "", str(getattr(reg, "registration_number", "") or "").upper())
|
||||
return code == "GSTIN" and len(num) == 15
|
||||
|
||||
|
||||
def _vault_entries(db, user, request, tenant_id: int, client_id: int, registration_id: int | None = None):
|
||||
q = select(CredentialVaultEntry).where(
|
||||
CredentialVaultEntry.tenant_id == tenant_id,
|
||||
CredentialVaultEntry.client_id == client_id,
|
||||
CredentialVaultEntry.status != "archived",
|
||||
)
|
||||
if registration_id:
|
||||
q = q.where(CredentialVaultEntry.registration_id == registration_id)
|
||||
rows = db.execute(q.order_by(CredentialVaultEntry.title.asc())).scalars().all()
|
||||
visible = [r for r in rows if can_view_entry(db, user, r, getattr(user, "branch_id", None))]
|
||||
gst = [r for r in visible if "gst" in (str(r.title or "") + " " + str(r.category or "") + " " + str(r.portal_url or "")).lower()]
|
||||
return gst or visible
|
||||
|
||||
|
||||
def _storage_payload(client, fy: str, gstin: str) -> tuple[str, str]:
|
||||
fy_folder = sanitize_segment(f"FY{fy}", "FY")
|
||||
letter, client_folder = client_folder_parts(client, int(client.id))
|
||||
root = Path(fy_folder) / "Clients" / letter / client_folder
|
||||
accounting = root / "Accounting"
|
||||
gst = root / "GST" / sanitize_segment(f"GSTIN_{gstin}", "GSTIN")
|
||||
return accounting.as_posix(), gst.as_posix()
|
||||
|
||||
|
||||
def _redirect(client_id: int, **params):
|
||||
data = {"client_id": client_id, **{k: v for k, v in params.items() if v not in (None, "")}}
|
||||
return RedirectResponse("/tools/accounting/gst-reconciliation?" + urlencode(data), status_code=303)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def page(request: Request, client_id: int | None = None, registration_id: int | None = None, period: str = "", message: str = "", error: str = ""):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_partner(request, db, "accounting.learning.view")
|
||||
if response:
|
||||
return response
|
||||
clients, scope = _visible_clients(db, request, user)
|
||||
selected = next((c for c in clients if client_id and int(c.id) == int(client_id)), None)
|
||||
registrations=[]; selected_reg=None; credentials=[]; node=None; status={}; analysis={}
|
||||
if selected:
|
||||
registrations=[(r,t) for r,t in _gst_regs(db,scope.tenant_id,selected.id) if _is_gstin(r,t)]
|
||||
selected_reg=next((r for r,t in registrations if registration_id and int(r.id)==int(registration_id)),None)
|
||||
if not selected_reg and registrations:
|
||||
selected_reg=registrations[0][0]
|
||||
credentials=_vault_entries(db,user,request,scope.tenant_id,selected.id,int(selected_reg.id) if selected_reg else None)
|
||||
node=get_active_storage_node_for_branch(db,scope.tenant_id,scope.branch_id)
|
||||
if selected_reg and period and node and _node_online(node):
|
||||
gstin=re.sub(r"\s+","",str(selected_reg.registration_number or "").upper())
|
||||
try:
|
||||
status=(request_agent_command(node.node_code,"gst_return_download_status",{"client_id":selected.id,"gstin":gstin,"period":re.sub(r"\D","",period)},timeout_seconds=8).get("result") or {}).get("job") or {}
|
||||
except Exception:
|
||||
status={}
|
||||
try:
|
||||
job_result=status.get("result") or {}
|
||||
# analysis is loaded only after an explicit Analyze action; status result is download manifest.
|
||||
analysis={}
|
||||
except Exception:
|
||||
pass
|
||||
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,"status":status,"analysis":analysis,"message":message,"error":error,"title":"GST Return Reconciliation",
|
||||
})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/download/start")
|
||||
def start_download(request: Request, client_id: int=Form(...), registration_id: int=Form(...), credential_id: int=Form(...), period: str=Form(...), include_2a: str=Form(""), csrf_token: str=Form(...)):
|
||||
validate_csrf(request,csrf_token)
|
||||
db=CommonSessionLocal()
|
||||
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=re.sub(r"\s+","",str(reg.registration_number or "").upper())
|
||||
cred=db.get(CredentialVaultEntry,credential_id)
|
||||
if not cred or int(cred.client_id or 0)!=int(client.id) or (cred.registration_id and int(cred.registration_id)!=int(reg.id)) or not can_view_entry(db,user,cred,scope.branch_id):
|
||||
return _redirect(client_id,registration_id=registration_id,period=period,error="Selected GST credential is not available for this client/registration.")
|
||||
username=decrypt_value(cred.tenant_id,cred.username_encrypted) or ""; password=decrypt_value(cred.tenant_id,cred.secret_encrypted) or ""
|
||||
if not username or not password: return _redirect(client_id,registration_id=registration_id,period=period,error="GST username/password is missing in Credential Vault.")
|
||||
fy=_fy_for_period(period); accounting_dir,gst_dir=_storage_payload(client,fy,gstin)
|
||||
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,error="Local Storage Agent is offline.")
|
||||
result=request_agent_command(node.node_code,"gst_return_download_start",{"client_id":client.id,"client_name":client.client_name,"gstin":gstin,"financial_year":fy,"period":re.sub(r"\D","",period),"gst_relative_dir":gst_dir,"accounting_relative_dir":accounting_dir,"username":username,"password":password,"include_2a":bool(include_2a),"login_timeout_seconds":900},timeout_seconds=15)
|
||||
log_access(db,request,user,cred,"use_for_gst_download",reason=f"GST return download {period}",fields="username,secret",success=bool(result.get("ok")))
|
||||
db.commit()
|
||||
if not result.get("ok"): return _redirect(client_id,registration_id=registration_id,period=period,error=result.get("error") or "GST download could not be started.")
|
||||
return _redirect(client_id,registration_id=registration_id,period=period,message="GST browser started on the Local Storage workstation. Complete captcha/OTP there; downloaded returns will be stored in the client GST directory.")
|
||||
except Exception as exc:
|
||||
db.rollback(); return _redirect(client_id,registration_id=registration_id,period=period,error=str(exc))
|
||||
finally: db.close()
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
def analyze(request: Request, client_id: int=Form(...), registration_id: int=Form(...), period: str=Form(...), csrf_token: str=Form(...)):
|
||||
validate_csrf(request,csrf_token)
|
||||
db=CommonSessionLocal()
|
||||
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="GSTIN registration was not found.")
|
||||
reg,_=pair; gstin=re.sub(r"\s+","",str(reg.registration_number or "").upper()); fy=_fy_for_period(period)
|
||||
accounting_dir,gst_dir=_storage_payload(client,fy,gstin); date_from,date_to=_period_bounds(period)
|
||||
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,error="Local Storage Agent is offline.")
|
||||
res=request_agent_command(node.node_code,"gst_reconciliation_analyze",{"client_id":client.id,"gstin":gstin,"financial_year":fy,"period":re.sub(r"\D","",period),"gst_relative_dir":gst_dir,"accounting_relative_dir":accounting_dir,"date_from":date_from,"date_to":date_to},timeout_seconds=25)
|
||||
if not res.get("ok"): return _redirect(client_id,registration_id=registration_id,period=period,error=res.get("error") or "GST reconciliation failed.")
|
||||
# Save compact analysis in session for immediate display; no GST raw data or credentials are stored on VPS.
|
||||
request.session["gst_reconciliation_result"]=(res.get("result") or {}).get("analysis") or {}
|
||||
return _redirect(client_id,registration_id=registration_id,period=period,message="GST Purchase, Sales and ITC reconciliation completed from local stored return data and Accounting Mirror.")
|
||||
except Exception as exc:
|
||||
return _redirect(client_id,registration_id=registration_id,period=period,error=str(exc))
|
||||
finally: db.close()
|
||||
@@ -0,0 +1,42 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% 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 portal data through Credential Vault, store it in 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-4">
|
||||
<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">Return Period (MMYYYY)<input name="period" value="{{ period }}" pattern="[0-9]{6}" placeholder="042026" class="mt-1 w-full rounded border p-2"></label>
|
||||
<div class="flex items-end"><button class="w-full rounded bg-slate-800 px-3 py-2 text-white">Load</button></div>
|
||||
</form>
|
||||
{% 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 }}">
|
||||
<h2 class="font-semibold">1. Download from GST Portal</h2>
|
||||
<p class="text-xs text-slate-500">The Local Storage Agent opens GST portal on the workstation. Username/password are taken from Credential Vault; captcha/OTP remains interactive. Raw return data is saved 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 }}">{{ c.title }}{% if c.reference_number %} — {{ c.reference_number }}{% endif %}</option>{% endfor %}</select></label>
|
||||
<label class="flex gap-2 text-sm"><input type="checkbox" name="include_2a" value="1"> Also download GSTR-2A</label>
|
||||
<button {% if not period or not node_online %}disabled{% endif %} class="rounded bg-indigo-600 px-4 py-2 text-white disabled:opacity-50">Start GST Download</button>
|
||||
<div class="text-xs">Storage Agent: <b>{{ 'Online' if node_online else 'Offline' }}</b></div>
|
||||
</form>
|
||||
<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 }}">
|
||||
<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. Reconciliation JSON is retained in the same local client GST directory.</p>
|
||||
<button {% if not period or not node_online %}disabled{% endif %} class="rounded bg-emerald-600 px-4 py-2 text-white disabled:opacity-50">Run Reconciliation</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if status %}<div class="rounded-xl border bg-white p-4"><h2 class="font-semibold">Download Status</h2><div class="mt-2 grid gap-2 text-sm md:grid-cols-3"><div>Status: <b>{{ status.status or '-' }}</b></div><div>Stage: {{ status.stage or '-' }}</div><div>{{ status.message or '' }}</div></div></div>{% endif %}
|
||||
{% set a=request.session.get('gst_reconciliation_result') or {} %}
|
||||
{% if a %}
|
||||
{% set s=a.get('sales_reconciliation',{}).get('counts',{}) %}{% set p=a.get('purchase_reconciliation',{}).get('counts',{}) %}
|
||||
<div class="rounded-xl border bg-white p-4 space-y-4"><h2 class="font-semibold">Reconciliation Summary — {{ a.get('period','') }}</h2>
|
||||
<div class="grid gap-3 md:grid-cols-2"><div class="rounded-lg border p-3"><h3 class="font-medium">Sales vs GSTR-1</h3><p class="text-sm">Books {{ s.get('books',0) }} · Portal {{ s.get('portal',0) }} · Matched {{ s.get('matched',0) }} · Missing in portal {{ s.get('missing_in_portal',0) }} · Missing in books {{ s.get('missing_in_books',0) }} · Value mismatch {{ s.get('value_mismatch',0) }}</p></div><div class="rounded-lg border p-3"><h3 class="font-medium">Purchases vs GSTR-2B</h3><p class="text-sm">Books {{ p.get('books',0) }} · 2B {{ p.get('portal',0) }} · Matched {{ p.get('matched',0) }} · Missing in 2B {{ p.get('missing_in_portal',0) }} · Missing in books {{ p.get('missing_in_books',0) }} · Value mismatch {{ p.get('value_mismatch',0) }}</p></div></div>
|
||||
<div><h3 class="font-medium mb-2">ITC: GSTR-2B vs GSTR-3B</h3><div class="overflow-x-auto"><table class="min-w-full text-sm"><thead><tr><th class="p-2 text-left">Tax</th><th class="p-2 text-right">GSTR-2B</th><th class="p-2 text-right">GSTR-3B</th><th class="p-2 text-right">Difference</th></tr></thead><tbody>{% for tax,row in a.get('itc_reconciliation',{}).items() %}<tr class="border-t"><td class="p-2 uppercase">{{ tax }}</td><td class="p-2 text-right">{{ '%.2f'|format(row.get('gstr2b',0)) }}</td><td class="p-2 text-right">{{ '%.2f'|format(row.get('gstr3b',0)) }}</td><td class="p-2 text-right">{{ '%.2f'|format(row.get('difference',0)) }}</td></tr>{% endfor %}</tbody></table></div></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -33,6 +33,7 @@
|
||||
<a href="/tools/accounting/historical-learning{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="block rounded-lg px-3 py-2 text-sm hover:bg-white">Historical Learning</a>
|
||||
<a href="/tools/accounting/ledger-learning{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="block rounded-lg px-3 py-2 text-sm hover:bg-white">Ledger Learning</a>
|
||||
<a href="/tools/accounting/gstr2b{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="block rounded-lg px-3 py-2 text-sm hover:bg-white">GSTR-2B Intelligence</a>
|
||||
<a href="/tools/accounting/gst-reconciliation{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="block rounded-lg bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-800 hover:bg-emerald-100">GST Return Reconciliation</a>
|
||||
<a href="/tools/accounting/purchase-enrichment{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="block rounded-lg px-3 py-2 text-sm hover:bg-white">E-Invoice / E-Way Bill</a>
|
||||
<a href="/tools/accounting/purchase-review{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="block rounded-lg px-3 py-2 text-sm hover:bg-white">Purchase Review</a>
|
||||
<a href="/tools/accounting/purchase-posting{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="block rounded-lg px-3 py-2 text-sm hover:bg-white">Purchase → Tally</a>
|
||||
|
||||
@@ -4,7 +4,7 @@ import io
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
ERP_LOCAL_AGENT_VERSION = "1.26.13"
|
||||
ERP_LOCAL_AGENT_VERSION = "1.26.14"
|
||||
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)
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
__version__ = "1.26.13"
|
||||
__version__ = "1.26.14"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
|
||||
@@ -14,6 +14,7 @@ from .accounting_store import LocalAccountingStore
|
||||
from .mirror_tally import MirrorFirstTallyConnector
|
||||
from .native_voucher_engine import NativeVoucherEngine
|
||||
from .tally_batch_queue import TallyBatchQueue
|
||||
from .gst_portal_runtime import start_download as gst_start_download, download_status as gst_download_status, analyze_gst_storage
|
||||
|
||||
|
||||
_CASH_TALLY_EXTRACTION_LOCK = threading.Lock()
|
||||
@@ -182,6 +183,12 @@ class AgentCommandProcessor:
|
||||
result = self._accounting_full_export_status(payload)
|
||||
elif action == "accounting_mirror_query":
|
||||
result = self._accounting_mirror_query(payload)
|
||||
elif action == "gst_return_download_start":
|
||||
result = self._gst_return_download_start(payload)
|
||||
elif action == "gst_return_download_status":
|
||||
result = self._gst_return_download_status(payload)
|
||||
elif action == "gst_reconciliation_analyze":
|
||||
result = self._gst_reconciliation_analyze(payload)
|
||||
elif action == "accounting_analysis_save":
|
||||
result = self._accounting_analysis_save(payload)
|
||||
elif action == "accounting_analysis_history":
|
||||
@@ -568,6 +575,57 @@ class AgentCommandProcessor:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _gst_return_download_start(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
client_id = int(payload.get("client_id") or 0)
|
||||
gstin = str(payload.get("gstin") or "").strip().upper()
|
||||
period = re.sub(r"\D", "", str(payload.get("period") or ""))
|
||||
relative_dir = str(payload.get("gst_relative_dir") or "").strip()
|
||||
username = str(payload.get("username") or "").strip()
|
||||
password = str(payload.get("password") or "")
|
||||
if client_id <= 0:
|
||||
raise ValueError("client_id is required.")
|
||||
if not re.fullmatch(r"\d{6}", period):
|
||||
raise ValueError("GST return period must be MMYYYY.")
|
||||
if len(gstin) != 15:
|
||||
raise ValueError("A valid GSTIN is required.")
|
||||
if not relative_dir:
|
||||
raise ValueError("GST local storage directory is required.")
|
||||
if not username or not password:
|
||||
raise ValueError("GST portal username/password are missing from the selected Credential Vault entry.")
|
||||
result = gst_start_download(payload, Path(self.config.storage_root), Path(__file__).resolve().parents[1] / "data")
|
||||
# Never echo credentials back to ERP or write them into result/progress files.
|
||||
return {**result, "agent": self._agent_info()}
|
||||
|
||||
def _gst_return_download_status(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
result = gst_download_status(payload, Path(__file__).resolve().parents[1] / "data")
|
||||
return {"job": result, "agent": self._agent_info()}
|
||||
|
||||
def _gst_reconciliation_analyze(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
client_id = int(payload.get("client_id") or 0)
|
||||
period = re.sub(r"\D", "", str(payload.get("period") or ""))
|
||||
gst_relative_dir = str(payload.get("gst_relative_dir") or "").strip()
|
||||
accounting_relative_dir = str(payload.get("accounting_relative_dir") or "").strip()
|
||||
date_from = str(payload.get("date_from") or "").strip()
|
||||
date_to = str(payload.get("date_to") or "").strip()
|
||||
if client_id <= 0 or not gst_relative_dir or not accounting_relative_dir:
|
||||
raise ValueError("Client GST and Accounting storage paths are required.")
|
||||
root = Path(self.config.storage_root)
|
||||
base_dir = root / Path(gst_relative_dir) / period
|
||||
mirror_db = root / Path(accounting_relative_dir) / f"client_{client_id:08d}_mirror.act"
|
||||
if not base_dir.exists():
|
||||
raise ValueError("GST data has not yet been downloaded for this client/period.")
|
||||
if not mirror_db.exists():
|
||||
raise ValueError("Accounting Mirror is not available for the selected financial year. Run Full Accounting Export first.")
|
||||
result = analyze_gst_storage(base_dir, mirror_db, period, date_from, date_to)
|
||||
compact = {
|
||||
"period": result.get("period"),
|
||||
"generated_at_utc": result.get("generated_at_utc"),
|
||||
"sales_reconciliation": {"counts": (result.get("sales_reconciliation") or {}).get("counts") or {}},
|
||||
"purchase_reconciliation": {"counts": (result.get("purchase_reconciliation") or {}).get("counts") or {}},
|
||||
"itc_reconciliation": result.get("itc_reconciliation") or {},
|
||||
}
|
||||
return {"analysis": compact, "report_path": str(base_dir / "reports" / f"{period}_GST_reconciliation.json"), "agent": self._agent_info()}
|
||||
|
||||
def _accounting_analysis_save(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
client_id = int(payload.get("client_id") or 0)
|
||||
if client_id <= 0:
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
GST_LOGIN_URL = "https://services.gst.gov.in/services/login"
|
||||
GSTR1_URL = "https://return.gst.gov.in/returns/auth/api/offline/download/generate?flag=0&rtn_prd={period}&rtn_typ=GSTR1"
|
||||
GSTR2A_URL = "https://return.gst.gov.in/returns/auth/api/offline/download/generate?flag=0&rtn_prd={period}&rtn_typ=GSTR2A"
|
||||
GSTR1_DOWNLOAD_URL = "https://return.gst.gov.in/returns/auth/api/offline/download/url?rtn_prd={period}&rtn_typ=GSTR1&file_num={file_num}"
|
||||
GSTR2B_URL = "https://gstr2b.gst.gov.in/gstr2b/auth/api/gstr2b/getjson?rtnprd={period}"
|
||||
GSTR3B_SUMMARY_URL = "https://return.gst.gov.in/returns/auth/api/gstr3b/summary?rtn_prd={period}"
|
||||
GSTR3B_URL = "https://return.gst.gov.in/returns/auth/api/gstr3b/taxpayble?rtn_prd={period}"
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
_THREADS: dict[str, threading.Thread] = {}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _safe(value: str, fallback: str = "item") -> str:
|
||||
value = re.sub(r"[^A-Za-z0-9._ -]+", "_", str(value or "").strip()).strip(" ._")
|
||||
return value or fallback
|
||||
|
||||
|
||||
def _parse_date(value: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
for fmt in ("%d-%m-%Y", "%d/%m/%Y", "%Y-%m-%d", "%d-%b-%Y", "%d/%m/%y"):
|
||||
try:
|
||||
return datetime.strptime(text, fmt).date().isoformat()
|
||||
except Exception:
|
||||
pass
|
||||
return text
|
||||
|
||||
|
||||
def _f(value: Any) -> float:
|
||||
try:
|
||||
return float(value or 0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _doc_key(value: str) -> str:
|
||||
return "".join(ch for ch in str(value or "").upper() if ch.isalnum())
|
||||
|
||||
|
||||
def _walk(obj: Any):
|
||||
if isinstance(obj, dict):
|
||||
yield obj
|
||||
for v in obj.values():
|
||||
yield from _walk(v)
|
||||
elif isinstance(obj, list):
|
||||
for v in obj:
|
||||
yield from _walk(v)
|
||||
|
||||
|
||||
def _tax_from_invoice(inv: dict[str, Any]) -> dict[str, float]:
|
||||
out = {"taxable": 0.0, "igst": 0.0, "cgst": 0.0, "sgst": 0.0, "cess": 0.0}
|
||||
for item in inv.get("itms") or inv.get("items") or []:
|
||||
d = item.get("itm_det") or item.get("item_det") or item
|
||||
out["taxable"] += _f(d.get("txval"))
|
||||
out["igst"] += _f(d.get("iamt"))
|
||||
out["cgst"] += _f(d.get("camt"))
|
||||
out["sgst"] += _f(d.get("samt"))
|
||||
out["cess"] += _f(d.get("csamt"))
|
||||
if not (out["taxable"] or out["igst"] or out["cgst"] or out["sgst"] or out["cess"]):
|
||||
out["taxable"] = _f(inv.get("txval") or inv.get("taxable_value"))
|
||||
out["igst"] = _f(inv.get("iamt") or inv.get("igst"))
|
||||
out["cgst"] = _f(inv.get("camt") or inv.get("cgst"))
|
||||
out["sgst"] = _f(inv.get("samt") or inv.get("sgst"))
|
||||
out["cess"] = _f(inv.get("csamt") or inv.get("cess"))
|
||||
return out
|
||||
|
||||
|
||||
def extract_invoice_rows(data: Any) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
for obj in _walk(data):
|
||||
invno = str(obj.get("inum") or obj.get("inv_num") or obj.get("doc_no") or obj.get("invoiceNumber") or "").strip()
|
||||
if not invno:
|
||||
continue
|
||||
gstin = str(obj.get("ctin") or obj.get("stin") or obj.get("supplier_gstin") or obj.get("recipientGstin") or "").strip().upper()
|
||||
date = _parse_date(str(obj.get("idt") or obj.get("inv_date") or obj.get("doc_date") or obj.get("invoiceDate") or ""))
|
||||
key = (gstin, _doc_key(invno), date)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
tax = _tax_from_invoice(obj)
|
||||
rows.append({
|
||||
"gstin": gstin,
|
||||
"invoice_no": invno,
|
||||
"invoice_key": _doc_key(invno),
|
||||
"invoice_date": date,
|
||||
"invoice_value": _f(obj.get("val") or obj.get("invoice_value") or obj.get("invoiceValue")),
|
||||
**tax,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def extract_gstr3b_itc(data: Any) -> dict[str, float]:
|
||||
totals = {"igst": 0.0, "cgst": 0.0, "sgst": 0.0, "cess": 0.0}
|
||||
if not isinstance(data, dict):
|
||||
return totals
|
||||
itc = data.get("itc_elg") or {}
|
||||
for item in itc.get("itc_avl") or []:
|
||||
totals["igst"] += _f(item.get("iamt"))
|
||||
totals["cgst"] += _f(item.get("camt"))
|
||||
totals["sgst"] += _f(item.get("samt"))
|
||||
totals["cess"] += _f(item.get("csamt"))
|
||||
return totals
|
||||
|
||||
|
||||
def normalize_downloads(base_dir: Path, period: str) -> dict[str, Any]:
|
||||
normalized = base_dir / "normalized"
|
||||
normalized.mkdir(parents=True, exist_ok=True)
|
||||
result: dict[str, Any] = {}
|
||||
for rtype in ("GSTR1", "GSTR2A", "GSTR2B", "GSTR3B"):
|
||||
raw_path = base_dir / "raw" / f"{period}_{rtype}.json"
|
||||
if not raw_path.exists():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(raw_path.read_text(encoding="utf-8", errors="ignore") or "{}")
|
||||
except Exception:
|
||||
data = {}
|
||||
if rtype in {"GSTR1", "GSTR2A", "GSTR2B"}:
|
||||
rows = extract_invoice_rows(data)
|
||||
_write_json(normalized / f"{period}_{rtype}_invoices.json", {"period": period, "rows": rows})
|
||||
result[rtype] = {"invoice_rows": len(rows)}
|
||||
else:
|
||||
itc = extract_gstr3b_itc(data)
|
||||
_write_json(normalized / f"{period}_{rtype}_itc.json", {"period": period, "itc": itc})
|
||||
result[rtype] = {"itc": itc}
|
||||
return result
|
||||
|
||||
|
||||
def _book_rows(mirror_db: Path, date_from: str, date_to: str, voucher_type: str) -> list[dict[str, Any]]:
|
||||
if not mirror_db.exists():
|
||||
return []
|
||||
con = sqlite3.connect(str(mirror_db))
|
||||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
sql = """
|
||||
SELECT voucher_date, voucher_type, voucher_number, party_ledger, reference, ABS(COALESCE(voucher_amount,0)) amount
|
||||
FROM voucher
|
||||
WHERE voucher_date >= ? AND voucher_date <= ? AND UPPER(COALESCE(voucher_type,'')) LIKE ?
|
||||
ORDER BY voucher_date, voucher_number
|
||||
"""
|
||||
rows = con.execute(sql, (date_from, date_to, f"%{voucher_type.upper()}%" )).fetchall()
|
||||
return [{"date": r["voucher_date"], "voucher_type": r["voucher_type"], "invoice_no": r["voucher_number"] or r["reference"] or "", "invoice_key": _doc_key(r["voucher_number"] or r["reference"] or ""), "party": r["party_ledger"] or "", "amount": _f(r["amount"])} for r in rows]
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def _match_books_to_portal(books: list[dict[str, Any]], portal: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
pmap: dict[str, list[dict[str, Any]]] = {}
|
||||
for row in portal:
|
||||
if row.get("invoice_key"):
|
||||
pmap.setdefault(row["invoice_key"], []).append(row)
|
||||
matched=[]; missing_portal=[]; used=set()
|
||||
for b in books:
|
||||
candidates=pmap.get(b.get("invoice_key") or "", [])
|
||||
best=None
|
||||
for p in candidates:
|
||||
pid=id(p)
|
||||
if pid in used: continue
|
||||
best=p; break
|
||||
if best is None:
|
||||
missing_portal.append(b); continue
|
||||
used.add(id(best))
|
||||
pv=_f(best.get("invoice_value")); bv=_f(b.get("amount")); diff=round(bv-pv,2)
|
||||
matched.append({"books":b,"portal":best,"difference":diff,"status":"Matched" if abs(diff)<=1 else "Value mismatch"})
|
||||
missing_books=[p for p in portal if id(p) not in used]
|
||||
return {"matched":matched,"missing_in_portal":missing_portal,"missing_in_books":missing_books,"counts":{"books":len(books),"portal":len(portal),"matched":len(matched),"missing_in_portal":len(missing_portal),"missing_in_books":len(missing_books),"value_mismatch":sum(1 for r in matched if r["status"]!="Matched")}}
|
||||
|
||||
|
||||
def analyze_gst_storage(base_dir: Path, mirror_db: Path, period: str, date_from: str, date_to: str) -> dict[str, Any]:
|
||||
norm=base_dir/"normalized"
|
||||
def rows(name):
|
||||
p=norm/f"{period}_{name}_invoices.json"
|
||||
d=_read_json(p) if p.exists() else {}
|
||||
return d.get("rows") or []
|
||||
g1=rows("GSTR1"); g2b=rows("GSTR2B")
|
||||
sales=_book_rows(mirror_db,date_from,date_to,"SALES")
|
||||
purchases=_book_rows(mirror_db,date_from,date_to,"PURCHASE")
|
||||
sales_rec=_match_books_to_portal(sales,g1)
|
||||
purchase_rec=_match_books_to_portal(purchases,g2b)
|
||||
g2b_itc={k:round(sum(_f(r.get(k)) for r in g2b),2) for k in ("igst","cgst","sgst","cess")}
|
||||
g3p=norm/f"{period}_GSTR3B_itc.json"; g3=_read_json(g3p).get("itc") if g3p.exists() else {}
|
||||
g3={k:round(_f((g3 or {}).get(k)),2) for k in ("igst","cgst","sgst","cess")}
|
||||
itc={k:{"gstr2b":g2b_itc[k],"gstr3b":g3[k],"difference":round(g3[k]-g2b_itc[k],2)} for k in g2b_itc}
|
||||
result={"period":period,"generated_at_utc":_now(),"sales_reconciliation":sales_rec,"purchase_reconciliation":purchase_rec,"itc_reconciliation":itc}
|
||||
reports=base_dir/"reports"; reports.mkdir(parents=True,exist_ok=True); _write_json(reports/f"{period}_GST_reconciliation.json",result)
|
||||
return result
|
||||
|
||||
|
||||
def _extract_file_num(text: str) -> str:
|
||||
try:
|
||||
data=json.loads(text or "{}")
|
||||
except Exception:
|
||||
return "1"
|
||||
vals=[]
|
||||
for obj in _walk(data):
|
||||
if isinstance(obj,dict):
|
||||
for key in ("file_num","fileNum","filenum","file_number","fileNumber"):
|
||||
if obj.get(key) not in (None,""):
|
||||
vals.append(str(obj.get(key)))
|
||||
return vals[0] if vals else "1"
|
||||
|
||||
|
||||
def _extract_payload(text: str) -> str:
|
||||
try:
|
||||
data=json.loads(text or "{}")
|
||||
except Exception:
|
||||
return text or ""
|
||||
if isinstance(data,dict):
|
||||
for key in ("data","json","payload","content","fileContent","response"):
|
||||
value=data.get(key)
|
||||
if isinstance(value,(dict,list)):
|
||||
return json.dumps(value,ensure_ascii=False)
|
||||
if isinstance(value,str) and value.lstrip().startswith(("{","[")):
|
||||
return value
|
||||
return text or ""
|
||||
|
||||
|
||||
def _browser_download(payload: dict[str, Any], progress_path: Path, storage_root: Path) -> None:
|
||||
base_dir = storage_root / Path(str(payload["gst_relative_dir"])) / str(payload["period"])
|
||||
raw_dir = base_dir / "raw"; raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
def progress(**kw):
|
||||
cur=_read_json(progress_path); cur.update(kw); cur["updated_at_utc"]=_now(); _write_json(progress_path,cur)
|
||||
try:
|
||||
progress(status="running",stage="Opening GST Login",message="Opening GST portal. Complete captcha/OTP in the browser.")
|
||||
from playwright.sync_api import sync_playwright
|
||||
with sync_playwright() as pw:
|
||||
browser=None; errors=[]
|
||||
profile=storage_root/".erp_gst_browser_profile"; profile.mkdir(parents=True,exist_ok=True)
|
||||
for channel in ("chrome","msedge"):
|
||||
try:
|
||||
browser=pw.chromium.launch_persistent_context(user_data_dir=str(profile/channel),channel=channel,headless=False,no_viewport=True,accept_downloads=True,args=["--start-maximized","--no-first-run","--disable-blink-features=AutomationControlled"])
|
||||
break
|
||||
except Exception as exc: errors.append(f"{channel}: {exc}")
|
||||
if browser is None: raise RuntimeError("Could not open Chrome/Edge through Playwright. "+" | ".join(errors))
|
||||
page=browser.pages[0] if browser.pages else browser.new_page()
|
||||
page.goto(GST_LOGIN_URL,wait_until="domcontentloaded",timeout=90000)
|
||||
page.wait_for_selector("#username",timeout=30000)
|
||||
page.fill("#username",str(payload.get("username") or "")); page.fill("#user_pass",str(payload.get("password") or ""))
|
||||
progress(stage="Waiting for GST Login",message="Username/password filled. Enter captcha and OTP in the GST browser. The agent will continue after login.")
|
||||
deadline=time.time()+int(payload.get("login_timeout_seconds") or 900)
|
||||
while time.time()<deadline:
|
||||
try:
|
||||
if "/dashboard" in page.url.lower() or await_login_probe(page): break
|
||||
except Exception: pass
|
||||
time.sleep(2)
|
||||
else: raise RuntimeError("GST login was not completed within 15 minutes.")
|
||||
downloaded=[]
|
||||
period=str(payload["period"])
|
||||
fetch_js="async (u)=>{const r=await fetch(u,{credentials:'include'}); return await r.text();}"
|
||||
|
||||
progress(stage="Downloading GSTR1",message=f"Generating and reading GSTR-1 for {period} from GST portal.")
|
||||
generate_text=page.evaluate(fetch_js,GSTR1_URL.format(period=period))
|
||||
(raw_dir/f"{period}_GSTR1_GENERATE.json").write_text(generate_text or "",encoding="utf-8",errors="ignore")
|
||||
file_num=_extract_file_num(generate_text)
|
||||
g1_text=page.evaluate(fetch_js,GSTR1_DOWNLOAD_URL.format(period=period,file_num=file_num))
|
||||
g1_text=_extract_payload(g1_text)
|
||||
g1_path=raw_dir/f"{period}_GSTR1.json"; g1_path.write_text(g1_text or "",encoding="utf-8",errors="ignore")
|
||||
downloaded.append({"return_type":"GSTR1","path":str(g1_path),"bytes":g1_path.stat().st_size})
|
||||
|
||||
progress(stage="Downloading GSTR2B",message=f"Reading GSTR-2B for {period} from GST portal.")
|
||||
g2_text=page.evaluate(fetch_js,GSTR2B_URL.format(period=period))
|
||||
g2_path=raw_dir/f"{period}_GSTR2B.json"; g2_path.write_text(g2_text or "",encoding="utf-8",errors="ignore")
|
||||
downloaded.append({"return_type":"GSTR2B","path":str(g2_path),"bytes":g2_path.stat().st_size})
|
||||
|
||||
if payload.get("include_2a"):
|
||||
progress(stage="Requesting GSTR2A",message=f"Requesting GSTR-2A offline data for {period}.")
|
||||
g2a_text=page.evaluate(fetch_js,GSTR2A_URL.format(period=period))
|
||||
g2a_path=raw_dir/f"{period}_GSTR2A.json"; g2a_path.write_text(g2a_text or "",encoding="utf-8",errors="ignore")
|
||||
downloaded.append({"return_type":"GSTR2A_GENERATE","path":str(g2a_path),"bytes":g2a_path.stat().st_size})
|
||||
|
||||
progress(stage="Downloading GSTR3B",message=f"Reading GSTR-3B summary and tax payable for {period}.")
|
||||
g3_summary=page.evaluate(fetch_js,GSTR3B_SUMMARY_URL.format(period=period))
|
||||
g3_payable=page.evaluate(fetch_js,GSTR3B_URL.format(period=period))
|
||||
try: combined=json.loads(g3_summary or "{}")
|
||||
except Exception: combined={"summary_raw":g3_summary or ""}
|
||||
if not isinstance(combined,dict): combined={"summary":combined}
|
||||
try: combined["taxpayble"]=json.loads(g3_payable or "{}")
|
||||
except Exception: combined["taxpayble"]={"raw":g3_payable or ""}
|
||||
(raw_dir/f"{period}_GSTR3B_SUMMARY.json").write_text(g3_summary or "",encoding="utf-8",errors="ignore")
|
||||
(raw_dir/f"{period}_GSTR3B_TAXPAYBLE.json").write_text(g3_payable or "",encoding="utf-8",errors="ignore")
|
||||
g3_path=raw_dir/f"{period}_GSTR3B.json"; g3_path.write_text(json.dumps(combined,ensure_ascii=False,indent=2),encoding="utf-8")
|
||||
downloaded.append({"return_type":"GSTR3B","path":str(g3_path),"bytes":g3_path.stat().st_size})
|
||||
|
||||
normalized=normalize_downloads(base_dir,period)
|
||||
manifest={"gstin":payload.get("gstin"),"period":payload["period"],"financial_year":payload.get("financial_year"),"downloaded_at_utc":_now(),"downloaded":downloaded,"normalized":normalized}
|
||||
_write_json(base_dir/"download_manifest.json",manifest)
|
||||
browser.close()
|
||||
progress(status="completed",stage="Completed",message="GST return data downloaded and stored in the client GST directory.",result=manifest,finished_at_utc=_now())
|
||||
except Exception as exc:
|
||||
progress(status="failed",stage="Failed",message=str(exc),error=str(exc),finished_at_utc=_now())
|
||||
|
||||
|
||||
def await_login_probe(page) -> bool:
|
||||
try:
|
||||
return bool(page.evaluate("async()=>{try{const r=await fetch('https://return.gst.gov.in/returns/auth/api/returns/profile',{credentials:'include'}); return r.status!==401 && r.status!==403;}catch(e){return false;}}"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def start_download(payload: dict[str, Any], storage_root: Path, progress_root: Path) -> dict[str, Any]:
|
||||
key=_safe(f"{payload.get('client_id')}_{payload.get('gstin')}_{payload.get('period')}")
|
||||
progress_path=progress_root/f"gst_download_{key}.json"
|
||||
current=_read_json(progress_path)
|
||||
if current.get("status") in {"queued","running"}:
|
||||
return {"started":False,"already_running":True,"job":current}
|
||||
initial={"status":"queued","stage":"Queued","message":"GST return download queued.","client_id":payload.get("client_id"),"gstin":payload.get("gstin"),"period":payload.get("period"),"started_at_utc":_now(),"updated_at_utc":_now()}
|
||||
_write_json(progress_path,initial)
|
||||
thread=threading.Thread(target=_browser_download,args=(dict(payload),progress_path,storage_root),daemon=True,name=f"gst-download-{key}")
|
||||
with _LOCK: _THREADS[key]=thread
|
||||
thread.start()
|
||||
return {"started":True,"job":initial}
|
||||
|
||||
|
||||
def download_status(payload: dict[str, Any], progress_root: Path) -> dict[str, Any]:
|
||||
key=_safe(f"{payload.get('client_id')}_{payload.get('gstin')}_{payload.get('period')}")
|
||||
return _read_json(progress_root/f"gst_download_{key}.json")
|
||||
@@ -2,3 +2,4 @@ requests==2.32.3
|
||||
python-dotenv==1.0.1
|
||||
websockets==12.0
|
||||
pywebview==5.3.2
|
||||
playwright==1.55.0
|
||||
|
||||
Reference in New Issue
Block a user