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)