Add operator workstation GST browser and full FY return download
This commit is contained in:
@@ -2,16 +2,20 @@ from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import re
|
||||
from datetime import date
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
import jwt
|
||||
from fastapi import APIRouter, File, Form, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse, 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.settings import get_settings
|
||||
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
|
||||
@@ -19,10 +23,13 @@ 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.documents.services import 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"])
|
||||
_TOKEN_PURPOSE = "gst_operator_browser_v1"
|
||||
_TOKEN_MINUTES = 30
|
||||
_UPLOAD_ROOT = Path(tempfile.gettempdir()) / "audit_firm_gst_operator_uploads"
|
||||
|
||||
|
||||
def _fy_bounds(fy: str) -> tuple[date, date]:
|
||||
@@ -33,6 +40,18 @@ def _fy_bounds(fy: str) -> tuple[date, date]:
|
||||
return date(y, 4, 1), date(y + 1, 3, 31)
|
||||
|
||||
|
||||
def _periods_for_fy(fy: str) -> list[str]:
|
||||
start, _ = _fy_bounds(fy)
|
||||
periods=[]
|
||||
y=start.year; m=4
|
||||
for _ in range(12):
|
||||
periods.append(f"{m:02d}{y:04d}")
|
||||
m += 1
|
||||
if m == 13:
|
||||
m = 1; y += 1
|
||||
return periods
|
||||
|
||||
|
||||
def _fy_for_period(period: str) -> str:
|
||||
digits = re.sub(r"\D", "", period or "")
|
||||
if len(digits) != 6:
|
||||
@@ -75,24 +94,15 @@ def _credential_is_gst_portal(entry: CredentialVaultEntry) -> bool:
|
||||
title = str(entry.title or "").strip().lower()
|
||||
portal_url = str(entry.portal_url or "").strip().lower()
|
||||
reference = str(entry.reference_number or "").strip().lower()
|
||||
|
||||
if category == "gst_portal":
|
||||
return True
|
||||
|
||||
# Older/client-level vault entries may have been saved under a generic category.
|
||||
# Accept them only when the entry itself clearly identifies a GST portal login.
|
||||
haystack = " ".join((title, portal_url, reference))
|
||||
gst_hint = (
|
||||
"gst portal" in haystack
|
||||
or "gst login" in haystack
|
||||
or "services.gst.gov.in" in portal_url
|
||||
or "www.gst.gov.in" in portal_url
|
||||
"gst portal" in haystack or "gst login" in haystack
|
||||
or "services.gst.gov.in" in portal_url or "www.gst.gov.in" in portal_url
|
||||
)
|
||||
if not gst_hint:
|
||||
return False
|
||||
|
||||
# Do not silently substitute E-Way Bill, E-Invoice or API/provider secrets for
|
||||
# a GST portal username/password unless the title explicitly says GST Portal/Login.
|
||||
if category in {"eway_bill", "einvoice", "api_key"}:
|
||||
return "gst portal" in title or "gst login" in title
|
||||
return True
|
||||
@@ -103,8 +113,6 @@ def _credential_matches_gstin(entry: CredentialVaultEntry, registration_id: int,
|
||||
return True
|
||||
if _norm_gstin(entry.reference_number) == _norm_gstin(gstin):
|
||||
return True
|
||||
# Client-level GST Portal credentials created before registration-level vault
|
||||
# linking remain valid candidates for the selected client's GST registration.
|
||||
return entry.registration_id is None
|
||||
|
||||
|
||||
@@ -117,25 +125,14 @@ def _vault_entries(db, user, request, tenant_id: int, client_id: int, registrati
|
||||
rows = db.execute(q.order_by(CredentialVaultEntry.title.asc())).scalars().all()
|
||||
branch_id = request.session.get("active_branch_id") or getattr(user, "branch_id", None)
|
||||
visible = [r for r in rows if can_view_entry(db, user, r, int(branch_id) if branch_id else None)]
|
||||
|
||||
if not registration_id:
|
||||
return [r for r in visible if _credential_is_gst_portal(r)]
|
||||
|
||||
eligible = [
|
||||
r for r in visible
|
||||
if _credential_is_gst_portal(r) and _credential_matches_gstin(r, int(registration_id), gstin)
|
||||
]
|
||||
|
||||
eligible = [r for r in visible if _credential_is_gst_portal(r) and _credential_matches_gstin(r, int(registration_id), gstin)]
|
||||
def rank(entry: CredentialVaultEntry):
|
||||
exact_registration = int(entry.registration_id or 0) == int(registration_id)
|
||||
exact_gstin = _norm_gstin(entry.reference_number) == _norm_gstin(gstin)
|
||||
client_level = entry.registration_id is None
|
||||
return (
|
||||
0 if exact_registration else 1 if exact_gstin else 2 if client_level else 3,
|
||||
str(entry.title or "").lower(),
|
||||
int(entry.id or 0),
|
||||
)
|
||||
|
||||
return (0 if exact_registration else 1 if exact_gstin else 2 if client_level else 3, str(entry.title or "").lower(), int(entry.id or 0))
|
||||
return sorted(eligible, key=rank)
|
||||
|
||||
|
||||
@@ -143,9 +140,7 @@ 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()
|
||||
return (root / "Accounting").as_posix(), (root / "GST" / sanitize_segment(f"GSTIN_{gstin}", "GSTIN")).as_posix()
|
||||
|
||||
|
||||
def _redirect(client_id: int, **params):
|
||||
@@ -153,8 +148,30 @@ def _redirect(client_id: int, **params):
|
||||
return RedirectResponse("/tools/accounting/gst-reconciliation?" + urlencode(data), status_code=303)
|
||||
|
||||
|
||||
def _encode_operator_token(payload: dict) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
body = {**payload, "purpose": _TOKEN_PURPOSE, "iat": now, "exp": now + timedelta(minutes=_TOKEN_MINUTES)}
|
||||
return jwt.encode(body, get_settings().SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def _decode_operator_token(token: str) -> dict:
|
||||
data = jwt.decode(token, get_settings().SECRET_KEY, algorithms=["HS256"])
|
||||
if data.get("purpose") != _TOKEN_PURPOSE:
|
||||
raise ValueError("Invalid GST operator token.")
|
||||
return data
|
||||
|
||||
|
||||
def _selected_periods(download_mode: str, financial_year: str, period: str) -> list[str]:
|
||||
if download_mode == "full_fy":
|
||||
return _periods_for_fy(financial_year)
|
||||
digits = re.sub(r"\D", "", period or "")
|
||||
if len(digits) != 6:
|
||||
raise ValueError("Enter a valid MMYYYY period for Single Month mode.")
|
||||
return [digits]
|
||||
|
||||
|
||||
@router.get("")
|
||||
def page(request: Request, client_id: int | None = None, registration_id: int | None = None, period: str = "", message: str = "", error: str = ""):
|
||||
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()
|
||||
try:
|
||||
user, response = _require_partner(request, db, "accounting.learning.view")
|
||||
@@ -162,40 +179,37 @@ def page(request: Request, client_id: int | None = None, registration_id: int |
|
||||
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={}
|
||||
registrations=[]; selected_reg=None; credentials=[]; node=None
|
||||
if not financial_year:
|
||||
financial_year = str(request.session.get("active_financial_year") or request.session.get("active_fy") or "2025-26")
|
||||
if download_mode not in {"single", "full_fy"}:
|
||||
download_mode = "single"
|
||||
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]
|
||||
selected_gstin = _norm_gstin(getattr(selected_reg, "registration_number", "")) if selected_reg else ""
|
||||
credentials=_vault_entries(
|
||||
db,user,request,scope.tenant_id,selected.id,
|
||||
int(selected_reg.id) if selected_reg else None, selected_gstin,
|
||||
)
|
||||
credentials=_vault_entries(db,user,request,scope.tenant_id,selected.id,int(selected_reg.id) if selected_reg else None, selected_gstin)
|
||||
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
|
||||
operator_job = request.session.pop("gst_operator_job", None)
|
||||
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",
|
||||
"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",
|
||||
})
|
||||
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(...)):
|
||||
def start_download(
|
||||
request: Request,
|
||||
client_id: int=Form(...), registration_id: int=Form(...), credential_id: int=Form(...),
|
||||
period: str=Form(""), financial_year: str=Form(...), download_mode: str=Form("single"),
|
||||
gstr1: str=Form(""), gstr2b: str=Form(""), gstr3b: str=Form(""), gstr2a: str=Form(""),
|
||||
csrf_token: str=Form(...),
|
||||
):
|
||||
validate_csrf(request,csrf_token)
|
||||
db=CommonSessionLocal()
|
||||
try:
|
||||
@@ -205,36 +219,107 @@ def start_download(request: Request, client_id: int=Form(...), registration_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())
|
||||
reg,_=pair; gstin=_norm_gstin(reg.registration_number)
|
||||
cred=db.get(CredentialVaultEntry,credential_id)
|
||||
credential_ok = bool(
|
||||
cred
|
||||
and int(cred.tenant_id or 0) == int(scope.tenant_id)
|
||||
and int(cred.client_id or 0) == int(client.id)
|
||||
and str(cred.status or "").lower() != "archived"
|
||||
and can_view_entry(db,user,cred,scope.branch_id)
|
||||
and _credential_is_gst_portal(cred)
|
||||
and _credential_matches_gstin(cred,int(reg.id),gstin)
|
||||
)
|
||||
credential_ok = bool(cred and int(cred.tenant_id or 0)==int(scope.tenant_id) and int(cred.client_id or 0)==int(client.id) and str(cred.status or "").lower()!="archived" and can_view_entry(db,user,cred,scope.branch_id) and _credential_is_gst_portal(cred) and _credential_matches_gstin(cred,int(reg.id),gstin))
|
||||
if not credential_ok:
|
||||
return _redirect(client_id,registration_id=registration_id,period=period,error="Selected GST Portal credential is not available for this client/GSTIN.")
|
||||
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)
|
||||
return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,error="Selected GST Portal credential is not available for this client/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")))
|
||||
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="Local Storage Agent is offline.")
|
||||
periods=_selected_periods(download_mode,financial_year,period)
|
||||
return_types=[name for name,flag in (("GSTR1",gstr1),("GSTR2B",gstr2b),("GSTR3B",gstr3b),("GSTR2A",gstr2a)) if flag]
|
||||
if not return_types:
|
||||
return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,error="Select at least one GST return to download.")
|
||||
# Ensure FY is consistent in single-month mode.
|
||||
if download_mode == "single":
|
||||
financial_year=_fy_for_period(periods[0])
|
||||
accounting_dir,gst_dir=_storage_payload(client,financial_year,gstin)
|
||||
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),"client_name":str(client.client_name or ""),
|
||||
"registration_id":int(reg.id),"credential_id":int(cred.id),"gstin":gstin,"financial_year":financial_year,
|
||||
"periods":periods,"return_types":return_types,"gst_relative_dir":gst_dir,"accounting_relative_dir":accounting_dir,
|
||||
})
|
||||
log_access(db,request,user,cred,"use_for_gst_download",reason=f"GST returns {financial_year}: {','.join(return_types)}",fields="username,secret",success=True)
|
||||
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.")
|
||||
request.session["gst_operator_job"]={"token":token,"job_id":jti,"periods":periods,"return_types":return_types}
|
||||
return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,message="GST download prepared. The ERP will now ask the Local Agent on this computer to open the visible GST browser; CAPTCHA/OTP will appear here. Completed files will be transferred to the configured client local storage.")
|
||||
except Exception as exc:
|
||||
db.rollback(); return _redirect(client_id,registration_id=registration_id,period=period,error=str(exc))
|
||||
finally: db.close()
|
||||
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()
|
||||
|
||||
|
||||
@router.post("/operator/redeem")
|
||||
async def operator_redeem(request: Request):
|
||||
body=await request.json(); token=str(body.get("token") or "")
|
||||
try:
|
||||
data=_decode_operator_token(token)
|
||||
db=CommonSessionLocal()
|
||||
try:
|
||||
cred=db.get(CredentialVaultEntry,int(data["credential_id"]))
|
||||
if not cred or int(cred.tenant_id or 0)!=int(data["tenant_id"]) or int(cred.client_id or 0)!=int(data["client_id"]) or str(cred.status or "").lower()=="archived":
|
||||
raise ValueError("GST credential is no longer available.")
|
||||
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:
|
||||
raise ValueError("GST username/password is missing in Credential Vault.")
|
||||
finally:
|
||||
db.close()
|
||||
return JSONResponse({"ok":True,"payload":{**{k:v for k,v in data.items() if k not in {"iat","exp","purpose"}},"username":username,"password":password,"upload_url":str(get_settings().ERP_PUBLIC_BASE_URL).rstrip("/")+"/tools/accounting/gst-reconciliation/operator/upload","login_timeout_seconds":900}})
|
||||
except Exception as exc:
|
||||
return JSONResponse({"ok":False,"error":str(exc)},status_code=400)
|
||||
|
||||
|
||||
@router.post("/operator/upload")
|
||||
async def operator_upload(token: str=Form(...), package: UploadFile=File(...)):
|
||||
try:
|
||||
data=_decode_operator_token(token); jti=str(data.get("jti") or "")
|
||||
if not jti:
|
||||
raise ValueError("GST operator job id is missing.")
|
||||
_UPLOAD_ROOT.mkdir(parents=True,exist_ok=True)
|
||||
package_path=_UPLOAD_ROOT/f"{jti}.zip"
|
||||
total=0
|
||||
with package_path.open("wb") as out:
|
||||
while True:
|
||||
chunk=await package.read(1024*1024)
|
||||
if not chunk: break
|
||||
total += len(chunk)
|
||||
if total > 250*1024*1024:
|
||||
raise ValueError("GST download package exceeds the 250 MB safety limit.")
|
||||
out.write(chunk)
|
||||
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(data["node_code"]),"gst_return_package_store",{
|
||||
"client_id":int(data["client_id"]),"gstin":str(data["gstin"]),"financial_year":str(data["financial_year"]),
|
||||
"gst_relative_dir":str(data["gst_relative_dir"]),"package_url":package_url,"periods":data.get("periods") or [],"return_types":data.get("return_types") or [],
|
||||
},timeout_seconds=120)
|
||||
if not result.get("ok"):
|
||||
raise RuntimeError(result.get("error") or "Local Storage Agent could not store the GST return package.")
|
||||
try: package_path.unlink(missing_ok=True)
|
||||
except Exception: pass
|
||||
return JSONResponse({"ok":True,"stored":result.get("result") or {}})
|
||||
except Exception as exc:
|
||||
return JSONResponse({"ok":False,"error":str(exc)},status_code=400)
|
||||
|
||||
|
||||
@router.get("/operator/package/{job_id}")
|
||||
def operator_package(job_id: str, token: str):
|
||||
try:
|
||||
data=_decode_operator_token(token)
|
||||
if str(data.get("jti") or "") != str(job_id):
|
||||
raise ValueError("GST package token does not match the requested job.")
|
||||
path=_UPLOAD_ROOT/f"{job_id}.zip"
|
||||
if not path.is_file():
|
||||
return JSONResponse({"ok":False,"error":"GST package is no longer available."},status_code=404)
|
||||
return FileResponse(path,media_type="application/zip",filename=f"gst_returns_{job_id}.zip")
|
||||
except Exception as exc:
|
||||
return JSONResponse({"ok":False,"error":str(exc)},status_code=400)
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
def analyze(request: Request, client_id: int=Form(...), registration_id: int=Form(...), period: str=Form(...), csrf_token: str=Form(...)):
|
||||
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)
|
||||
db=CommonSessionLocal()
|
||||
try:
|
||||
@@ -244,15 +329,36 @@ def analyze(request: Request, client_id: int=Form(...), registration_id: int=For
|
||||
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)
|
||||
reg,_=pair; gstin=_norm_gstin(reg.registration_number)
|
||||
periods=_periods_for_fy(financial_year) if analyze_mode=="full_fy" else [re.sub(r"\D","",period or "")]
|
||||
if any(len(p)!=6 for p in periods): raise ValueError("Enter a valid MMYYYY period.")
|
||||
accounting_dir,gst_dir=_storage_payload(client,financial_year,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.")
|
||||
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.")
|
||||
if not node or not _node_online(node): return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,error="Local Storage Agent is offline.")
|
||||
results=[]
|
||||
for p in periods:
|
||||
date_from,date_to=_period_bounds(p)
|
||||
res=request_agent_command(node.node_code,"gst_reconciliation_analyze",{"client_id":client.id,"gstin":gstin,"financial_year":financial_year,"period":p,"gst_relative_dir":gst_dir,"accounting_relative_dir":accounting_dir,"date_from":date_from,"date_to":date_to},timeout_seconds=30)
|
||||
if res.get("ok"):
|
||||
results.append((res.get("result") or {}).get("analysis") or {})
|
||||
if not results: raise RuntimeError("No stored GST periods could be reconciled.")
|
||||
if analyze_mode=="full_fy":
|
||||
def sum_counts(section):
|
||||
out={}
|
||||
for r in results:
|
||||
for k,v in (((r.get(section) or {}).get("counts") or {}).items()): out[k]=out.get(k,0)+int(v or 0)
|
||||
return out
|
||||
taxes={k:{"gstr2b":0.0,"gstr3b":0.0,"difference":0.0} for k in ("igst","cgst","sgst","cess")}
|
||||
for r in results:
|
||||
for k,row in (r.get("itc_reconciliation") or {}).items():
|
||||
if k in taxes:
|
||||
for f in taxes[k]: taxes[k][f]=round(taxes[k][f]+float(row.get(f) or 0),2)
|
||||
analysis={"period":financial_year,"sales_reconciliation":{"counts":sum_counts("sales_reconciliation")},"purchase_reconciliation":{"counts":sum_counts("purchase_reconciliation")},"itc_reconciliation":taxes}
|
||||
else:
|
||||
analysis=results[0]
|
||||
request.session["gst_reconciliation_result"]=analysis
|
||||
return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=("full_fy" if analyze_mode=="full_fy" else "single"),message="GST Purchase, Sales and ITC reconciliation completed from stored local 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()
|
||||
return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,error=str(exc))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -1,49 +1,56 @@
|
||||
{% 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>
|
||||
<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>
|
||||
<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">
|
||||
|
||||
<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">Return Period (MMYYYY)<input name="period" value="{{ period }}" pattern="[0-9]{6}" placeholder="042026" class="mt-1 w-full rounded border p-2"></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>
|
||||
<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>
|
||||
</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 }}">
|
||||
<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 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 }}" {% if credentials|length == 1 %}selected{% endif %}>{{ c.title }}{% if c.reference_number %} — {{ c.reference_number }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% if not credentials %}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900">
|
||||
No usable GST Portal credential is linked to this client/GSTIN.
|
||||
<a class="font-semibold underline" href="/credential-vault/new?client_id={{ selected_client.id }}®istration_id={{ selected_registration.id }}">Add GST Portal credential</a>.
|
||||
<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>
|
||||
{% elif credentials|length == 1 %}
|
||||
<div class="text-xs text-slate-500">The only eligible GST Portal credential has been selected automatically.</div>
|
||||
{% endif %}
|
||||
<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 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">Storage Agent: <b>{{ 'Online' if node_online else 'Offline' }}</b></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>
|
||||
<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>
|
||||
</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 }}">
|
||||
<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. 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>
|
||||
<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>
|
||||
<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>
|
||||
{% 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 %}
|
||||
|
||||
<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>
|
||||
|
||||
{% 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',{}) %}
|
||||
@@ -54,4 +61,34 @@
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
|
||||
Reference in New Issue
Block a user