Switch GST reconciliation to direct browser and manual return import
This commit is contained in:
@@ -4,6 +4,9 @@ import calendar
|
|||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlencode
|
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.models import CredentialVaultEntry
|
||||||
from app.modules.credential_vault.service import can_view_entry, log_access
|
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.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
|
from app.modules.registrations.models import ClientRegistration, RegistrationType
|
||||||
|
|
||||||
router = APIRouter(prefix="/tools/accounting/gst-reconciliation", tags=["accounting-gst-reconciliation-ui"])
|
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]
|
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("")
|
@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 = ""):
|
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()
|
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",{
|
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),
|
"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,
|
"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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -318,6 +419,136 @@ def operator_package(job_id: str, token: str):
|
|||||||
return JSONResponse({"ok":False,"error":str(exc)},status_code=400)
|
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")
|
@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(...)):
|
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)
|
validate_csrf(request,csrf_token)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mx-auto max-w-7xl space-y-5 p-4">
|
<div class="mx-auto max-w-7xl space-y-5 p-4">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div><h1 class="text-2xl font-bold">GST Return Reconciliation</h1><p class="text-sm text-slate-600">Download GST returns on the workstation where you are using ERP, store them in the configured client local storage, and reconcile against Accounting Mirror.</p></div>
|
<div><h1 class="text-2xl font-bold">GST Return Reconciliation</h1><p class="text-sm text-slate-600">Open GST Portal directly in your browser, import downloaded return files into the configured client local storage, and reconcile against Accounting Mirror.</p></div>
|
||||||
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-lg border px-3 py-2 text-sm">Back to Accounting</a>
|
<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>
|
||||||
{% if message %}<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-emerald-800">{{ message }}</div>{% endif %}
|
{% if message %}<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-emerald-800">{{ message }}</div>{% endif %}
|
||||||
@@ -19,37 +19,53 @@
|
|||||||
|
|
||||||
{% if selected_client and selected_registration %}
|
{% if selected_client and selected_registration %}
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<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">
|
<div class="rounded-xl border bg-white p-4 space-y-4">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="registration_id" value="{{ selected_registration.id }}">
|
<div>
|
||||||
<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>
|
<h2 class="font-semibold">1. Download from GST Portal</h2>
|
||||||
<p class="text-xs text-slate-500">The visible GST browser opens on <b>this computer</b> through the ERP Local Agent at 127.0.0.1:8788. CAPTCHA/OTP remains visible here. After download, return data is transferred to the configured Local Storage Agent and stored under the client's FY/GST/GSTIN/period directory.</p>
|
<p class="mt-1 text-sm text-slate-600">Open the official GST Portal directly in this browser. No ERP Local Agent is required on this computer. Complete login, CAPTCHA and OTP normally, then download the required return JSON/ZIP files.</p>
|
||||||
<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>
|
</div>
|
||||||
<button {% if not node_online or not credentials %}disabled{% endif %} class="rounded bg-indigo-600 px-4 py-2 text-white disabled:opacity-50">Start GST Download</button>
|
<label class="text-sm">Credential Vault Entry
|
||||||
|
<select class="mt-1 w-full rounded border p-2" disabled>
|
||||||
|
{% if credentials %}{% for c in credentials %}<option {% if credentials|length==1 %}selected{% endif %}>{{ c.title }}{% if c.reference_number %} — {{ c.reference_number }}{% endif %}</option>{% endfor %}{% else %}<option>No GST Portal credential found</option>{% endif %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-slate-500">Credential Vault remains linked to this GSTIN for reference and future API-based workflows. The browser login is manual because the GST Portal is on a different website and ERP cannot read or autofill that page across browser security boundaries.</p>
|
||||||
|
<a href="{{ gst_login_url }}" target="_blank" rel="noopener noreferrer" class="inline-flex rounded bg-indigo-600 px-4 py-2 text-white">Open GST Portal</a>
|
||||||
|
<div class="rounded-lg border bg-slate-50 p-3 text-xs text-slate-600">
|
||||||
|
<b>For Full Financial Year:</b> download the selected GSTR-1, GSTR-2B and GSTR-3B JSON/ZIP files for all required months. You can select multiple files below in one import.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/tools/accounting/gst-reconciliation/import" enctype="multipart/form-data" class="rounded-xl border bg-white p-4 space-y-3">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||||
|
<input type="hidden" name="registration_id" value="{{ selected_registration.id }}">
|
||||||
|
<input type="hidden" name="period" value="{{ period }}">
|
||||||
|
<input type="hidden" name="financial_year" value="{{ financial_year }}">
|
||||||
|
<input type="hidden" name="download_mode" value="{{ download_mode }}">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold">2. Import Downloaded Returns to Client Storage</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-600">Select the JSON or ZIP files downloaded from GST Portal. ERP identifies each return period, normalizes the data, and transfers it to the configured Local Storage Agent under the client's FY/GST/GSTIN/period directory.</p>
|
||||||
|
</div>
|
||||||
|
<label class="block text-sm">GSTR-1 files<input type="file" name="gstr1_files" accept=".json,.zip,application/json,application/zip" multiple class="mt-1 block w-full rounded border p-2"></label>
|
||||||
|
<label class="block text-sm">GSTR-2B files<input type="file" name="gstr2b_files" accept=".json,.zip,application/json,application/zip" multiple class="mt-1 block w-full rounded border p-2"></label>
|
||||||
|
<label class="block text-sm">GSTR-3B files<input type="file" name="gstr3b_files" accept=".json,.zip,application/json,application/zip" multiple class="mt-1 block w-full rounded border p-2"></label>
|
||||||
|
<label class="block text-sm">GSTR-2A files <span class="text-slate-400">(optional)</span><input type="file" name="gstr2a_files" accept=".json,.zip,application/json,application/zip" multiple class="mt-1 block w-full rounded border p-2"></label>
|
||||||
|
<button {% if not node_online %}disabled{% endif %} class="rounded bg-sky-700 px-4 py-2 text-white disabled:opacity-50">Import & Store GST Returns</button>
|
||||||
<div class="text-xs">Configured Storage Agent: <b>{{ 'Online' if node_online else 'Offline' }}</b></div>
|
<div class="text-xs">Configured Storage Agent: <b>{{ 'Online' if node_online else 'Offline' }}</b></div>
|
||||||
<div class="text-xs text-slate-500">The operator workstation also needs ERP Local Agent 1.26.15+ running so the browser can open here.</div>
|
<div class="text-xs text-slate-500">No Local Agent is required on the computer where this browser is open.</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/tools/accounting/gst-reconciliation/analyze" class="rounded-xl border bg-white p-4 space-y-3">
|
<form method="post" action="/tools/accounting/gst-reconciliation/analyze" class="rounded-xl border bg-white p-4 space-y-3">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="registration_id" value="{{ selected_registration.id }}"><input type="hidden" name="period" value="{{ period }}"><input type="hidden" name="financial_year" value="{{ financial_year }}">
|
<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>
|
<h2 class="font-semibold">3. Reconcile Stored Data</h2>
|
||||||
<p class="text-sm text-slate-600">Sales: Accounting Mirror vs GSTR-1. Purchases: Accounting Mirror vs GSTR-2B. ITC: GSTR-2B vs GSTR-3B. Analysis reads only stored local return data after download.</p>
|
<p class="text-sm text-slate-600">Sales: Accounting Mirror vs GSTR-1. Purchases: Accounting Mirror vs GSTR-2B. ITC: GSTR-2B vs GSTR-3B. Analysis reads only return data already stored in the client's local-storage GST directory.</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>
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<label class="text-sm min-w-64">Analysis scope<select name="analyze_mode" class="mt-1 w-full rounded border p-2"><option value="single">Single Month</option><option value="full_fy" {% if download_mode=='full_fy' %}selected{% endif %}>Full Financial Year</option></select></label>
|
||||||
<button {% if not node_online %}disabled{% endif %} class="rounded bg-emerald-600 px-4 py-2 text-white disabled:opacity-50">Run Reconciliation</button>
|
<button {% if not node_online %}disabled{% endif %} class="rounded bg-emerald-600 px-4 py-2 text-white disabled:opacity-50">Run Reconciliation</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="operatorStatus" class="hidden rounded-xl border bg-white p-4">
|
|
||||||
<div class="flex items-center justify-between"><h2 class="font-semibold">Interactive GST Download</h2><span id="operatorPct" class="text-sm font-semibold"></span></div>
|
|
||||||
<div class="mt-2 h-2 overflow-hidden rounded bg-slate-100"><div id="operatorBar" class="h-2 bg-indigo-600" style="width:0%"></div></div>
|
|
||||||
<div id="operatorMessage" class="mt-3 text-sm text-slate-700"></div>
|
|
||||||
<div id="operatorDetail" class="mt-1 text-xs text-slate-500"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% set a=request.session.get('gst_reconciliation_result') or {} %}
|
{% set a=request.session.get('gst_reconciliation_result') or {} %}
|
||||||
{% if a %}
|
{% if a %}
|
||||||
@@ -62,33 +78,5 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user