248 lines
9.1 KiB
Python
248 lines
9.1 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from urllib.parse import urlencode
|
|
|
|
from fastapi import APIRouter, File, Form, Request, UploadFile
|
|
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.gstr2b_models import AccountingGSTR2BImportBatch, AccountingGSTR2BPurchase
|
|
from app.modules.accounting.gstr2b_service import (
|
|
analyze_batch,
|
|
batches_for_client,
|
|
import_gstr2b,
|
|
nature_lookup,
|
|
purchases_for_client,
|
|
review_purchase,
|
|
)
|
|
from app.modules.accounting.historical_learning_service import active_natures, ledger_mappings
|
|
from app.modules.accounting.ledger_learning_service import available_tally_guids
|
|
from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients
|
|
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
|
|
|
router = APIRouter(prefix="/tools/accounting/gstr2b", tags=["accounting-gstr2b-ui"])
|
|
|
|
|
|
def _redirect(client_id: int, *, batch_id: int | None = None, message: str = "", error: str = ""):
|
|
params = {"client_id": client_id}
|
|
if batch_id:
|
|
params["batch_id"] = batch_id
|
|
if message:
|
|
params["message"] = message
|
|
if error:
|
|
params["error"] = error[:180]
|
|
return RedirectResponse(url="/tools/accounting/gstr2b?" + urlencode(params), status_code=303)
|
|
|
|
|
|
@router.get("")
|
|
def gstr2b_page(
|
|
request: Request,
|
|
client_id: int | None = None,
|
|
batch_id: int | None = None,
|
|
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)
|
|
|
|
batches = []
|
|
purchases = []
|
|
mappings = []
|
|
companies = []
|
|
natures = []
|
|
nature_by_id = {}
|
|
selected_batch = None
|
|
|
|
if selected:
|
|
batches = batches_for_client(db, scope.tenant_id, selected.id)
|
|
selected_batch = next((b for b in batches if batch_id and b.id == batch_id), None)
|
|
if not selected_batch and batches:
|
|
selected_batch = batches[0]
|
|
purchases = purchases_for_client(
|
|
db, scope.tenant_id, selected.id,
|
|
batch_id=selected_batch.id if selected_batch else None
|
|
)
|
|
mappings = ledger_mappings(
|
|
db, scope.tenant_id, selected.id,
|
|
selected_batch.tally_guid if selected_batch else ""
|
|
)
|
|
companies = available_tally_guids(db, scope.tenant_id, selected.id)
|
|
natures = active_natures(db, scope.tenant_id)
|
|
ids = []
|
|
for row in purchases:
|
|
ids.extend([row.suggested_nature_id, row.final_nature_id])
|
|
nature_by_id = nature_lookup(db, ids)
|
|
|
|
explanations = {}
|
|
for row in purchases:
|
|
try:
|
|
explanations[row.id] = json.loads(row.suggestion_explanation_json or "[]")
|
|
except Exception:
|
|
explanations[row.id] = []
|
|
|
|
return templates.TemplateResponse("modules/accounting/templates/accounting/gstr2b.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),
|
|
"title": "GSTR-2B Purchase Intelligence",
|
|
"clients": clients,
|
|
"selected_client": selected,
|
|
"batches": batches,
|
|
"selected_batch": selected_batch,
|
|
"purchases": purchases,
|
|
"mappings": mappings,
|
|
"tally_companies": companies,
|
|
"natures": natures,
|
|
"nature_by_id": nature_by_id,
|
|
"explanations": explanations,
|
|
"message": message,
|
|
"error": error,
|
|
})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/upload")
|
|
async def upload_gstr2b(
|
|
request: Request,
|
|
client_id: int = Form(...),
|
|
tally_guid: str = Form(""),
|
|
return_period: str = Form(""),
|
|
upload: UploadFile = File(...),
|
|
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:
|
|
from app.core.http_responses import ui_access_denied
|
|
return ui_access_denied()
|
|
content = await upload.read()
|
|
batch, duplicate_file = import_gstr2b(
|
|
db,
|
|
tenant_id=scope.tenant_id,
|
|
client_id=client.id,
|
|
tally_guid=tally_guid,
|
|
return_period=return_period,
|
|
filename=upload.filename or "gstr2b.xlsx",
|
|
content=content,
|
|
user_id=user.id,
|
|
)
|
|
if duplicate_file:
|
|
msg = f"This exact GSTR-2B file was already imported as batch #{batch.id}."
|
|
else:
|
|
msg = (
|
|
f"Imported {batch.rows_imported} purchase document(s); "
|
|
f"{batch.rows_skipped_duplicate} duplicate(s) and "
|
|
f"{batch.rows_skipped_invalid} invalid row(s) skipped."
|
|
)
|
|
return _redirect(client.id, batch_id=batch.id, message=msg)
|
|
except Exception as exc:
|
|
db.rollback()
|
|
return _redirect(client_id, error=str(exc))
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/batch/{batch_id}/analyze")
|
|
def analyze(
|
|
request: Request,
|
|
batch_id: int,
|
|
client_id: int = 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:
|
|
from app.core.http_responses import ui_access_denied
|
|
return ui_access_denied()
|
|
batch = db.execute(select(AccountingGSTR2BImportBatch).where(
|
|
AccountingGSTR2BImportBatch.id == batch_id,
|
|
AccountingGSTR2BImportBatch.tenant_id == scope.tenant_id,
|
|
AccountingGSTR2BImportBatch.client_id == client.id,
|
|
)).scalar_one_or_none()
|
|
if not batch:
|
|
return _redirect(client_id, error="GSTR-2B import batch was not found.")
|
|
count = analyze_batch(db, batch)
|
|
return _redirect(client_id, batch_id=batch.id, message=f"Analyzed {count} purchase document(s).")
|
|
except Exception as exc:
|
|
db.rollback()
|
|
return _redirect(client_id, batch_id=batch_id, error=str(exc))
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/purchase/{purchase_id}/review")
|
|
def review(
|
|
request: Request,
|
|
purchase_id: int,
|
|
client_id: int = Form(...),
|
|
final_nature_id: int = Form(...),
|
|
final_ledger_name: 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:
|
|
from app.core.http_responses import ui_access_denied
|
|
return ui_access_denied()
|
|
row = db.execute(select(AccountingGSTR2BPurchase).where(
|
|
AccountingGSTR2BPurchase.id == purchase_id,
|
|
AccountingGSTR2BPurchase.tenant_id == scope.tenant_id,
|
|
AccountingGSTR2BPurchase.client_id == client.id,
|
|
)).scalar_one_or_none()
|
|
if not row:
|
|
return _redirect(client_id, error="GSTR-2B purchase record was not found.")
|
|
|
|
# If a ledger is selected, ensure it is one of the Phase 5 mappings for this nature/company.
|
|
if final_ledger_name.strip():
|
|
allowed = ledger_mappings(db, scope.tenant_id, client.id, row.tally_guid)
|
|
valid = any(
|
|
m.ledger_name == final_ledger_name.strip() and int(m.nature_id) == int(final_nature_id)
|
|
for m in allowed
|
|
)
|
|
if not valid:
|
|
return _redirect(
|
|
client_id, batch_id=row.batch_id,
|
|
error="Selected Tally ledger is not mapped to the selected accounting nature for this client/company."
|
|
)
|
|
|
|
review_purchase(
|
|
db,
|
|
row=row,
|
|
final_nature_id=final_nature_id,
|
|
final_ledger_name=final_ledger_name,
|
|
user_id=user.id,
|
|
)
|
|
return _redirect(client_id, batch_id=row.batch_id, message=f"Invoice {row.invoice_number} reviewed and learned.")
|
|
except Exception as exc:
|
|
db.rollback()
|
|
return _redirect(client_id, error=str(exc))
|
|
finally:
|
|
db.close()
|