Files
arrr-erp/app/modules/accounting/purchase_enrichment_ui.py
T
2026-08-22 15:30:46 +05:30

262 lines
9.8 KiB
Python

from __future__ import annotations
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 AccountingGSTR2BPurchase
from app.modules.accounting.gstr2b_service import purchases_for_client
from app.modules.accounting.ledger_learning_service import available_tally_guids
from app.modules.accounting.purchase_enrichment_models import (
AccountingPurchaseEnrichmentBatch,
AccountingPurchaseEnrichmentRecord,
)
from app.modules.accounting.purchase_enrichment_service import (
batches_for_client,
import_enrichment,
items_for_records,
manually_link_record,
records_for_batch,
rematch_batch,
unlink_record,
)
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/purchase-enrichment", tags=["accounting-purchase-enrichment-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[:220]
if error:
params["error"] = error[:220]
return RedirectResponse(
url="/tools/accounting/purchase-enrichment?" + urlencode(params),
status_code=303,
)
@router.get("")
def 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 = []
selected_batch = None
records = []
items = {}
gstr2b_rows = []
gstr2b_by_id = {}
companies = []
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]
if selected_batch:
records = records_for_batch(db, selected_batch.id)
items = items_for_records(db, [r.id for r in records])
gstr2b_rows = purchases_for_client(db, scope.tenant_id, selected.id, limit=500)
gstr2b_by_id = {r.id: r for r in gstr2b_rows}
companies = available_tally_guids(db, scope.tenant_id, selected.id)
return templates.TemplateResponse(
"modules/accounting/templates/accounting/purchase_enrichment.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": "E-Invoice / E-Way Bill Enrichment",
"clients": clients,
"selected_client": selected,
"batches": batches,
"selected_batch": selected_batch,
"records": records,
"items_by_record": items,
"gstr2b_rows": gstr2b_rows,
"gstr2b_by_id": gstr2b_by_id,
"tally_companies": companies,
"message": message,
"error": error,
},
)
finally:
db.close()
@router.post("/upload")
async def upload(
request: Request,
client_id: int = Form(...),
source_type: str = Form(...),
source_period: str = Form(""),
tally_guid: 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_enrichment(
db,
tenant_id=scope.tenant_id,
client_id=client.id,
tally_guid=tally_guid,
source_type=source_type,
source_period=source_period,
filename=upload.filename or "source.json",
content=content,
user_id=user.id,
)
if duplicate_file:
msg = f"This exact {source_type.replace('_', ' ')} file was already imported as batch #{batch.id}."
else:
msg = (
f"Imported {batch.records_imported} document(s): "
f"{batch.records_linked} linked, {batch.records_unmatched} unmatched, "
f"{batch.records_ambiguous} ambiguous, {batch.records_duplicate} duplicate(s)."
)
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}/rematch")
def rematch(
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(AccountingPurchaseEnrichmentBatch).where(
AccountingPurchaseEnrichmentBatch.id == batch_id,
AccountingPurchaseEnrichmentBatch.tenant_id == scope.tenant_id,
AccountingPurchaseEnrichmentBatch.client_id == client.id,
)).scalar_one_or_none()
if not batch:
return _redirect(client_id, error="Enrichment batch was not found.")
count = rematch_batch(db, batch)
return _redirect(client_id, batch_id=batch.id, message=f"Re-matched {count} enrichment document(s).")
except Exception as exc:
db.rollback()
return _redirect(client_id, batch_id=batch_id, error=str(exc))
finally:
db.close()
@router.post("/record/{record_id}/link")
def manual_link(
request: Request,
record_id: int,
client_id: int = Form(...),
purchase_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()
record = db.execute(select(AccountingPurchaseEnrichmentRecord).where(
AccountingPurchaseEnrichmentRecord.id == record_id,
AccountingPurchaseEnrichmentRecord.tenant_id == scope.tenant_id,
AccountingPurchaseEnrichmentRecord.client_id == client.id,
)).scalar_one_or_none()
purchase = 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 record or not purchase:
return _redirect(client_id, error="Record or GSTR-2B purchase was not found.")
manually_link_record(db, record, purchase)
return _redirect(client_id, batch_id=record.batch_id, message=f"Linked to GSTR-2B invoice {purchase.invoice_number}.")
except Exception as exc:
db.rollback()
return _redirect(client_id, error=str(exc))
finally:
db.close()
@router.post("/record/{record_id}/unlink")
def manual_unlink(
request: Request,
record_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()
record = db.execute(select(AccountingPurchaseEnrichmentRecord).where(
AccountingPurchaseEnrichmentRecord.id == record_id,
AccountingPurchaseEnrichmentRecord.tenant_id == scope.tenant_id,
AccountingPurchaseEnrichmentRecord.client_id == client.id,
)).scalar_one_or_none()
if not record:
return _redirect(client_id, error="Enrichment record was not found.")
batch_id = record.batch_id
unlink_record(db, record)
return _redirect(client_id, batch_id=batch_id, message="Enrichment source was unlinked.")
except Exception as exc:
db.rollback()
return _redirect(client_id, error=str(exc))
finally:
db.close()