Add Phase 8 E-Invoice and E-Way Bill purchase enrichment
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user