Add Phase 9 purchase review board
This commit is contained in:
@@ -0,0 +1,153 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from collections import defaultdict
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
|
||||||
|
from app.modules.accounting.gstr2b_models import AccountingGSTR2BPurchase
|
||||||
|
from app.modules.accounting.gstr2b_service import nature_lookup, review_purchase
|
||||||
|
from app.modules.accounting.historical_learning_service import active_natures, ledger_mappings
|
||||||
|
from app.modules.accounting.purchase_enrichment_models import AccountingPurchaseEnrichmentRecord
|
||||||
|
|
||||||
|
VALID_STATUSES = {"all", "pending_analysis", "suggested", "review_required", "reviewed"}
|
||||||
|
VALID_CONFIDENCE = {"all", "high", "medium", "low"}
|
||||||
|
VALID_SOURCE = {"all", "gstr2b_only", "enriched"}
|
||||||
|
PER_PAGE_CHOICES = {10, 25, 50, 100}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_page(value):
|
||||||
|
try: return max(1, int(value))
|
||||||
|
except Exception: return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_per_page(value):
|
||||||
|
try: value = int(value)
|
||||||
|
except Exception: value = 25
|
||||||
|
return value if value in PER_PAGE_CHOICES else 25
|
||||||
|
|
||||||
|
|
||||||
|
def _base_stmt(tenant_id, client_id):
|
||||||
|
enriched = (
|
||||||
|
select(
|
||||||
|
AccountingPurchaseEnrichmentRecord.gstr2b_purchase_id.label("purchase_id"),
|
||||||
|
func.count(AccountingPurchaseEnrichmentRecord.id).label("enrichment_count"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
AccountingPurchaseEnrichmentRecord.gstr2b_purchase_id.is_not(None),
|
||||||
|
AccountingPurchaseEnrichmentRecord.match_status == "linked",
|
||||||
|
)
|
||||||
|
.group_by(AccountingPurchaseEnrichmentRecord.gstr2b_purchase_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
AccountingGSTR2BPurchase,
|
||||||
|
func.coalesce(enriched.c.enrichment_count, 0).label("enrichment_count"),
|
||||||
|
)
|
||||||
|
.outerjoin(enriched, enriched.c.purchase_id == AccountingGSTR2BPurchase.id)
|
||||||
|
.where(
|
||||||
|
AccountingGSTR2BPurchase.tenant_id == tenant_id,
|
||||||
|
AccountingGSTR2BPurchase.client_id == client_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return stmt, enriched
|
||||||
|
|
||||||
|
|
||||||
|
def review_queue(db, *, tenant_id, client_id, tally_guid="", status="all", confidence="all", source="all", supplier="", return_period="", page=1, per_page=25):
|
||||||
|
status = status if status in VALID_STATUSES else "all"
|
||||||
|
confidence = confidence if confidence in VALID_CONFIDENCE else "all"
|
||||||
|
source = source if source in VALID_SOURCE else "all"
|
||||||
|
page = _safe_page(page); per_page = _safe_per_page(per_page)
|
||||||
|
stmt, enriched = _base_stmt(tenant_id, client_id)
|
||||||
|
if tally_guid: stmt = stmt.where(AccountingGSTR2BPurchase.tally_guid == tally_guid)
|
||||||
|
if status != "all": stmt = stmt.where(AccountingGSTR2BPurchase.review_status == status)
|
||||||
|
if confidence == "high": stmt = stmt.where(AccountingGSTR2BPurchase.suggested_confidence >= 85)
|
||||||
|
elif confidence == "medium": stmt = stmt.where(and_(AccountingGSTR2BPurchase.suggested_confidence >= 60, AccountingGSTR2BPurchase.suggested_confidence < 85))
|
||||||
|
elif confidence == "low": stmt = stmt.where(AccountingGSTR2BPurchase.suggested_confidence < 60)
|
||||||
|
if source == "enriched": stmt = stmt.where(func.coalesce(enriched.c.enrichment_count, 0) > 0)
|
||||||
|
elif source == "gstr2b_only": stmt = stmt.where(func.coalesce(enriched.c.enrichment_count, 0) == 0)
|
||||||
|
supplier = (supplier or "").strip()
|
||||||
|
if supplier:
|
||||||
|
pattern = f"%{supplier}%"
|
||||||
|
stmt = stmt.where(or_(
|
||||||
|
AccountingGSTR2BPurchase.supplier_name.ilike(pattern),
|
||||||
|
AccountingGSTR2BPurchase.supplier_gstin.ilike(pattern),
|
||||||
|
AccountingGSTR2BPurchase.invoice_number.ilike(pattern),
|
||||||
|
))
|
||||||
|
if return_period: stmt = stmt.where(AccountingGSTR2BPurchase.return_period == return_period.strip())
|
||||||
|
total = int(db.execute(select(func.count()).select_from(stmt.order_by(None).subquery())).scalar_one() or 0)
|
||||||
|
pages = max(1, math.ceil(total / per_page)) if total else 1
|
||||||
|
page = min(page, pages)
|
||||||
|
rows = list(db.execute(stmt.order_by(AccountingGSTR2BPurchase.invoice_date.desc(), AccountingGSTR2BPurchase.id.desc()).offset((page-1)*per_page).limit(per_page)).all())
|
||||||
|
return {"rows": rows, "total": total, "page": page, "per_page": per_page, "pages": pages}
|
||||||
|
|
||||||
|
|
||||||
|
def review_counts(db, *, tenant_id, client_id):
|
||||||
|
base = [AccountingGSTR2BPurchase.tenant_id == tenant_id, AccountingGSTR2BPurchase.client_id == client_id]
|
||||||
|
total = int(db.execute(select(func.count(AccountingGSTR2BPurchase.id)).where(*base)).scalar_one() or 0)
|
||||||
|
reviewed = int(db.execute(select(func.count(AccountingGSTR2BPurchase.id)).where(*base, AccountingGSTR2BPurchase.review_status == "reviewed")).scalar_one() or 0)
|
||||||
|
high = int(db.execute(select(func.count(AccountingGSTR2BPurchase.id)).where(*base, AccountingGSTR2BPurchase.review_status != "reviewed", AccountingGSTR2BPurchase.suggested_nature_id.is_not(None), AccountingGSTR2BPurchase.suggested_confidence >= 85)).scalar_one() or 0)
|
||||||
|
exceptions = int(db.execute(select(func.count(AccountingGSTR2BPurchase.id)).where(*base, AccountingGSTR2BPurchase.review_status != "reviewed", or_(AccountingGSTR2BPurchase.review_status == "review_required", AccountingGSTR2BPurchase.suggested_nature_id.is_(None), AccountingGSTR2BPurchase.suggested_confidence < 60))).scalar_one() or 0)
|
||||||
|
enriched_ids = select(AccountingPurchaseEnrichmentRecord.gstr2b_purchase_id).where(AccountingPurchaseEnrichmentRecord.tenant_id == tenant_id, AccountingPurchaseEnrichmentRecord.client_id == client_id, AccountingPurchaseEnrichmentRecord.gstr2b_purchase_id.is_not(None), AccountingPurchaseEnrichmentRecord.match_status == "linked").distinct()
|
||||||
|
enriched = int(db.execute(select(func.count(AccountingGSTR2BPurchase.id)).where(*base, AccountingGSTR2BPurchase.id.in_(enriched_ids))).scalar_one() or 0)
|
||||||
|
return {"total": total, "reviewed": reviewed, "pending": max(0,total-reviewed), "high": high, "exceptions": exceptions, "enriched": enriched}
|
||||||
|
|
||||||
|
|
||||||
|
def return_periods(db, *, tenant_id, client_id):
|
||||||
|
return list(db.execute(select(AccountingGSTR2BPurchase.return_period).where(AccountingGSTR2BPurchase.tenant_id == tenant_id, AccountingGSTR2BPurchase.client_id == client_id, AccountingGSTR2BPurchase.return_period != "").distinct().order_by(AccountingGSTR2BPurchase.return_period.desc())).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
def nature_maps_for_rows(db, rows):
|
||||||
|
ids=[]
|
||||||
|
for row, _count in rows: ids.extend([row.suggested_nature_id,row.final_nature_id])
|
||||||
|
return nature_lookup(db, ids)
|
||||||
|
|
||||||
|
|
||||||
|
def explanations_for_rows(rows):
|
||||||
|
result={}
|
||||||
|
for row,_count in rows:
|
||||||
|
try: result[row.id]=json.loads(row.suggestion_explanation_json or "[]")
|
||||||
|
except Exception: result[row.id]=[]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def mappings_by_nature(db, tenant_id, client_id, tally_guid=""):
|
||||||
|
result=defaultdict(list)
|
||||||
|
for row in ledger_mappings(db,tenant_id,client_id,tally_guid): result[int(row.nature_id)].append(row)
|
||||||
|
for values in result.values(): values.sort(key=lambda x:(str(x.ledger_name or "").casefold(),x.id))
|
||||||
|
return dict(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_choice(db, *, tenant_id, client_id, purchase, nature_id, ledger_name):
|
||||||
|
valid_natures={int(n.id) for n in active_natures(db,tenant_id)}
|
||||||
|
if int(nature_id) not in valid_natures: raise ValueError("Select an active accounting nature.")
|
||||||
|
ledger_name=(ledger_name or "").strip()
|
||||||
|
if not ledger_name: return
|
||||||
|
allowed=ledger_mappings(db,tenant_id,client_id,purchase.tally_guid)
|
||||||
|
if not any(m.ledger_name == ledger_name and int(m.nature_id)==int(nature_id) for m in allowed):
|
||||||
|
raise ValueError("Selected Tally ledger is not mapped to the selected accounting nature for this client/company.")
|
||||||
|
|
||||||
|
|
||||||
|
def review_one(db, *, tenant_id, client_id, purchase_id, final_nature_id, final_ledger_name, user_id):
|
||||||
|
row=db.execute(select(AccountingGSTR2BPurchase).where(AccountingGSTR2BPurchase.id==purchase_id,AccountingGSTR2BPurchase.tenant_id==tenant_id,AccountingGSTR2BPurchase.client_id==client_id)).scalar_one_or_none()
|
||||||
|
if not row: raise ValueError("Purchase record was not found.")
|
||||||
|
_validate_choice(db,tenant_id=tenant_id,client_id=client_id,purchase=row,nature_id=final_nature_id,ledger_name=final_ledger_name)
|
||||||
|
return review_purchase(db,row=row,final_nature_id=int(final_nature_id),final_ledger_name=(final_ledger_name or "").strip(),user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def bulk_confirm(db, *, tenant_id, client_id, purchase_ids, user_id):
|
||||||
|
ids=sorted({int(x) for x in purchase_ids if str(x).isdigit() and int(x)>0})
|
||||||
|
if not ids: raise ValueError("Select at least one high-confidence purchase.")
|
||||||
|
rows=list(db.execute(select(AccountingGSTR2BPurchase).where(AccountingGSTR2BPurchase.tenant_id==tenant_id,AccountingGSTR2BPurchase.client_id==client_id,AccountingGSTR2BPurchase.id.in_(ids))).scalars().all())
|
||||||
|
by_id={r.id:r for r in rows}
|
||||||
|
if len(by_id)!=len(ids): raise ValueError("One or more selected purchases are outside this client scope.")
|
||||||
|
completed=0
|
||||||
|
for pid in ids:
|
||||||
|
row=by_id[pid]
|
||||||
|
if row.review_status=="reviewed": continue
|
||||||
|
if not row.suggested_nature_id or int(row.suggested_confidence or 0)<85: raise ValueError(f"Invoice {row.invoice_number} is not eligible for high-confidence bulk confirmation.")
|
||||||
|
_validate_choice(db,tenant_id=tenant_id,client_id=client_id,purchase=row,nature_id=row.suggested_nature_id,ledger_name=row.suggested_ledger_name or "")
|
||||||
|
review_purchase(db,row=row,final_nature_id=int(row.suggested_nature_id),final_ledger_name=row.suggested_ledger_name or "",user_id=user_id)
|
||||||
|
completed+=1
|
||||||
|
return completed
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from fastapi import APIRouter, Form, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
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.historical_learning_service import active_natures
|
||||||
|
from app.modules.accounting.ledger_learning_service import available_tally_guids
|
||||||
|
from app.modules.accounting.purchase_review_service import (
|
||||||
|
review_queue, review_counts, return_periods, nature_maps_for_rows,
|
||||||
|
explanations_for_rows, mappings_by_nature, review_one, bulk_confirm,
|
||||||
|
)
|
||||||
|
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-review",tags=["accounting-purchase-review-ui"])
|
||||||
|
|
||||||
|
|
||||||
|
def _qs(client_id, filters, page):
|
||||||
|
params={"client_id":client_id,"tally_guid":filters.get("tally_guid", ""),"status":filters.get("status","all"),"confidence":filters.get("confidence","all"),"source":filters.get("source","all"),"supplier":filters.get("supplier",""),"return_period":filters.get("return_period",""),"per_page":filters.get("per_page",25),"page":page}
|
||||||
|
return "/tools/accounting/purchase-review?"+urlencode(params)
|
||||||
|
|
||||||
|
|
||||||
|
def _redirect(client_id, filters, *, message="", error="", page=None):
|
||||||
|
params=dict(filters); params["client_id"]=client_id; params["page"]=page or filters.get("page",1)
|
||||||
|
if message: params["message"]=message[:220]
|
||||||
|
if error: params["error"]=error[:220]
|
||||||
|
return RedirectResponse(url="/tools/accounting/purchase-review?"+urlencode(params),status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def board(request:Request,client_id:int|None=None,tally_guid:str="",status:str="all",confidence:str="all",source:str="all",supplier:str="",return_period:str="",page:int=1,per_page:int=25,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)
|
||||||
|
result=counts=None; natures=[]; nature_by_id={}; mappings={}; explanations={}; periods=[]; companies=[]; prev_url=next_url=""
|
||||||
|
filters={"tally_guid":tally_guid,"status":status,"confidence":confidence,"source":source,"supplier":supplier,"return_period":return_period,"per_page":per_page,"page":page}
|
||||||
|
if selected:
|
||||||
|
result=review_queue(db,tenant_id=scope.tenant_id,client_id=selected.id,tally_guid=tally_guid,status=status,confidence=confidence,source=source,supplier=supplier,return_period=return_period,page=page,per_page=per_page)
|
||||||
|
counts=review_counts(db,tenant_id=scope.tenant_id,client_id=selected.id)
|
||||||
|
natures=active_natures(db,scope.tenant_id)
|
||||||
|
nature_by_id=nature_maps_for_rows(db,result["rows"])
|
||||||
|
mappings=mappings_by_nature(db,scope.tenant_id,selected.id,tally_guid)
|
||||||
|
explanations=explanations_for_rows(result["rows"])
|
||||||
|
periods=return_periods(db,tenant_id=scope.tenant_id,client_id=selected.id)
|
||||||
|
companies=available_tally_guids(db,scope.tenant_id,selected.id)
|
||||||
|
if result["page"]>1:prev_url=_qs(selected.id,filters,result["page"]-1)
|
||||||
|
if result["page"]<result["pages"]:next_url=_qs(selected.id,filters,result["page"]+1)
|
||||||
|
return templates.TemplateResponse("modules/accounting/templates/accounting/purchase_review.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":"Purchase Review Board","clients":clients,"selected_client":selected,"result":result,"counts":counts,"natures":natures,"nature_by_id":nature_by_id,"mappings_by_nature":mappings,"explanations":explanations,"return_periods":periods,"tally_companies":companies,"filters":filters,"prev_url":prev_url,"next_url":next_url,"message":message,"error":error})
|
||||||
|
finally:db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/purchase/{purchase_id}/review")
|
||||||
|
def review_purchase_row(request:Request,purchase_id:int,client_id:int=Form(...),final_nature_id:int=Form(...),final_ledger_name:str=Form(""),tally_guid:str=Form(""),status:str=Form("all"),confidence:str=Form("all"),source:str=Form("all"),supplier:str=Form(""),return_period:str=Form(""),page:int=Form(1),per_page:int=Form(25),csrf_token:str=Form(...)):
|
||||||
|
validate_csrf(request,csrf_token); db=CommonSessionLocal(); filters={"tally_guid":tally_guid,"status":status,"confidence":confidence,"source":source,"supplier":supplier,"return_period":return_period,"page":page,"per_page":per_page}
|
||||||
|
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=review_one(db,tenant_id=scope.tenant_id,client_id=client.id,purchase_id=purchase_id,final_nature_id=final_nature_id,final_ledger_name=final_ledger_name,user_id=user.id)
|
||||||
|
return _redirect(client.id,filters,message=f"Invoice {row.invoice_number} reviewed.")
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback(); return _redirect(client_id,filters,error=str(exc))
|
||||||
|
finally:db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk-confirm")
|
||||||
|
async def bulk_confirm_rows(request:Request):
|
||||||
|
form=await request.form(); validate_csrf(request,str(form.get("csrf_token") or ""))
|
||||||
|
client_id=int(form.get("client_id") or 0); filters={"tally_guid":str(form.get("tally_guid") or ""),"status":str(form.get("status") or "all"),"confidence":str(form.get("confidence") or "all"),"source":str(form.get("source") or "all"),"supplier":str(form.get("supplier") or ""),"return_period":str(form.get("return_period") or ""),"page":int(form.get("page") or 1),"per_page":int(form.get("per_page") or 25)}
|
||||||
|
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()
|
||||||
|
count=bulk_confirm(db,tenant_id=scope.tenant_id,client_id=client.id,purchase_ids=form.getlist("purchase_ids"),user_id=user.id)
|
||||||
|
return _redirect(client.id,filters,message=f"Confirmed {count} high-confidence purchase(s).")
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback(); return _redirect(client_id,filters,error=str(exc))
|
||||||
|
finally:db.close()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "ui/templates/base/layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div><p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting Intelligence</p><h1 class="mt-1 text-2xl font-semibold text-slate-900">Purchase Review Board</h1><p class="mt-1 max-w-4xl text-sm text-slate-500">Unified Phase 9 review queue for GSTR-2B purchases and linked E-Invoice/E-Way Bill enrichment. Review, correct or bulk-confirm high-confidence suggestions. Nothing is posted to Tally.</p></div>
|
||||||
|
<div class="flex flex-wrap gap-2"><a href="/tools/accounting/gstr2b{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">GSTR-2B</a><a href="/tools/accounting/purchase-enrichment{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">E-Invoice / E-Way Bill</a><a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Back to Tally</a></div>
|
||||||
|
</div>
|
||||||
|
{% if message %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">{{ message }}</div>{% endif %}
|
||||||
|
{% if error %}<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{{ error }}</div>{% endif %}
|
||||||
|
<section class="rounded-2xl bg-white p-5 shadow-soft"><form method="get" class="grid gap-4 md:grid-cols-2 xl:grid-cols-8">
|
||||||
|
<label class="text-sm font-medium text-slate-700 xl:col-span-2">ERP Client<select name="client_id" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm"><option value="">Select client</option>{% for c in clients %}<option value="{{ c.id }}" {% if selected_client and selected_client.id==c.id %}selected{% endif %}>{{ c.client_name }}</option>{% endfor %}</select></label>
|
||||||
|
{% if selected_client %}
|
||||||
|
<label class="text-sm font-medium text-slate-700 xl:col-span-2">Tally Company<select name="tally_guid" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm"><option value="">All companies</option>{% for guid,company in tally_companies %}<option value="{{ guid }}" {% if filters.tally_guid==guid %}selected{% endif %}>{{ company or guid }} · {{ guid }}</option>{% endfor %}</select></label>
|
||||||
|
<label class="text-sm font-medium text-slate-700">Status<select name="status" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm"><option value="all">All</option><option value="suggested" {% if filters.status=='suggested' %}selected{% endif %}>Suggested</option><option value="review_required" {% if filters.status=='review_required' %}selected{% endif %}>Review Required</option><option value="reviewed" {% if filters.status=='reviewed' %}selected{% endif %}>Reviewed</option><option value="pending_analysis" {% if filters.status=='pending_analysis' %}selected{% endif %}>Pending Analysis</option></select></label>
|
||||||
|
<label class="text-sm font-medium text-slate-700">Confidence<select name="confidence" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm"><option value="all">All</option><option value="high" {% if filters.confidence=='high' %}selected{% endif %}>High ≥85%</option><option value="medium" {% if filters.confidence=='medium' %}selected{% endif %}>60–84%</option><option value="low" {% if filters.confidence=='low' %}selected{% endif %}>Low <60%</option></select></label>
|
||||||
|
<label class="text-sm font-medium text-slate-700">Source<select name="source" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm"><option value="all">All</option><option value="enriched" {% if filters.source=='enriched' %}selected{% endif %}>Enriched</option><option value="gstr2b_only" {% if filters.source=='gstr2b_only' %}selected{% endif %}>GSTR-2B only</option></select></label>
|
||||||
|
<label class="text-sm font-medium text-slate-700">Period<select name="return_period" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm"><option value="">All</option>{% for p in return_periods %}<option value="{{ p }}" {% if filters.return_period==p %}selected{% endif %}>{{ p }}</option>{% endfor %}</select></label>
|
||||||
|
<label class="text-sm font-medium text-slate-700 xl:col-span-2">Supplier / GSTIN / Invoice<input name="supplier" value="{{ filters.supplier }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></label>
|
||||||
|
<label class="text-sm font-medium text-slate-700">Rows<select name="per_page" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">{% for n in [10,25,50,100] %}<option value="{{ n }}" {% if result and result.per_page==n %}selected{% endif %}>{{ n }}</option>{% endfor %}</select></label>
|
||||||
|
{% endif %}<div class="flex items-end"><button class="w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Apply</button></div>
|
||||||
|
</form></section>
|
||||||
|
{% if selected_client and counts %}
|
||||||
|
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-6">{% for label,value,klass in [('Total',counts.total,''),('Reviewed',counts.reviewed,'text-emerald-700'),('Pending',counts.pending,''),('High Confidence',counts.high,'text-blue-700'),('Exceptions',counts.exceptions,'text-amber-700'),('Enriched',counts.enriched,'')] %}<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">{{ label }}</div><div class="mt-2 text-2xl font-semibold {{ klass }}">{{ value }}</div></div>{% endfor %}</section>
|
||||||
|
{% if result %}
|
||||||
|
<form id="bulk-form" method="post" action="/tools/accounting/purchase-review/bulk-confirm" class="hidden">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">{% for k in ['tally_guid','status','confidence','source','supplier','return_period','page','per_page'] %}<input type="hidden" name="{{ k }}" value="{{ filters[k] }}">{% endfor %}
|
||||||
|
</form>
|
||||||
|
<section class="rounded-2xl bg-white shadow-soft"><div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-5 py-4"><div><h2 class="font-semibold text-slate-900">Purchase Queue</h2><p class="mt-1 text-sm text-slate-500">{{ result.total }} records · page {{ result.page }} of {{ result.pages }}</p></div><button type="submit" form="bulk-form" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Confirm Selected High-Confidence</button></div>
|
||||||
|
<div class="overflow-x-auto"><table class="min-w-full text-sm"><thead><tr class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase text-slate-500"><th class="px-3 py-3"><input id="select-high" type="checkbox"></th><th class="px-3 py-3">Supplier / Invoice</th><th class="px-3 py-3">Source</th><th class="px-3 py-3 text-right">Taxable</th><th class="px-3 py-3">Suggestion</th><th class="px-3 py-3">Final Treatment</th><th class="px-3 py-3">Review</th></tr></thead><tbody>
|
||||||
|
{% for pair in result.rows %}{% set row=pair[0] %}{% set enrich=pair[1] %}{% set suggested=nature_by_id.get(row.suggested_nature_id) if row.suggested_nature_id else None %}{% set final=nature_by_id.get(row.final_nature_id) if row.final_nature_id else None %}
|
||||||
|
<tr class="border-b border-slate-100 align-top"><td class="px-3 py-4">{% if row.review_status!='reviewed' and suggested and row.suggested_confidence>=85 %}<input class="high-select" type="checkbox" name="purchase_ids" value="{{ row.id }}" form="bulk-form">{% endif %}</td>
|
||||||
|
<td class="px-3 py-4 min-w-[220px]"><div class="font-semibold text-slate-900">{{ row.supplier_name or row.supplier_gstin }}</div><div class="text-xs text-slate-500">{{ row.supplier_gstin or '-' }}</div><div class="mt-2">{{ row.invoice_number }} · {{ row.invoice_date }}</div></td>
|
||||||
|
<td class="px-3 py-4"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold">GSTR-2B</span>{% if enrich %}<span class="ml-1 rounded-full bg-indigo-50 px-2 py-1 text-xs font-semibold text-indigo-700">+{{ enrich }} enrichment</span>{% endif %}</td>
|
||||||
|
<td class="px-3 py-4 text-right whitespace-nowrap"><div class="font-semibold">₹{{ '%.2f'|format(row.taxable_value) }}</div><div class="text-xs text-slate-500">Invoice ₹{{ '%.2f'|format(row.invoice_value) }}</div></td>
|
||||||
|
<td class="px-3 py-4 min-w-[250px]">{% if suggested %}<div class="flex gap-2"><span class="font-semibold">{{ suggested.name }}</span><span class="rounded-full px-2 py-1 text-xs font-semibold {% if row.suggested_confidence>=85 %}bg-emerald-50 text-emerald-700{% elif row.suggested_confidence>=60 %}bg-blue-50 text-blue-700{% else %}bg-amber-50 text-amber-700{% endif %}">{{ row.suggested_confidence }}%</span></div><div class="text-sm text-slate-600">{{ row.suggested_ledger_name or 'No mapped Tally ledger' }}</div>{% if explanations.get(row.id) %}<details class="mt-2"><summary class="cursor-pointer text-xs font-semibold text-brand-600">Why?</summary><ul class="mt-1 text-xs text-slate-500">{% for x in explanations.get(row.id)[:5] %}<li>• {{ x }}</li>{% endfor %}</ul></details>{% endif %}{% else %}<span class="rounded-full bg-amber-50 px-2 py-1 text-xs font-semibold text-amber-700">Review required</span>{% endif %}</td>
|
||||||
|
<td class="px-3 py-4 min-w-[220px]">{% if row.review_status=='reviewed' %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3"><div class="text-xs font-semibold uppercase text-emerald-700">Reviewed</div><div class="font-semibold">{{ final.name if final else '-' }}</div><div class="text-sm text-slate-600">{{ row.final_ledger_name or 'No ledger' }}</div></div>{% else %}<span class="text-xs text-slate-500">Awaiting review</span>{% endif %}</td>
|
||||||
|
<td class="px-3 py-4 min-w-[330px]"><form method="post" action="/tools/accounting/purchase-review/purchase/{{ row.id }}/review" class="space-y-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">{% for k in ['tally_guid','status','confidence','source','supplier','return_period','page','per_page'] %}<input type="hidden" name="{{ k }}" value="{{ filters[k] }}">{% endfor %}<select name="final_nature_id" required class="w-full rounded-lg border border-slate-300 bg-white px-2 py-2 text-sm">{% for nature in natures %}<option value="{{ nature.id }}" {% if (final and final.id==nature.id) or (not final and suggested and suggested.id==nature.id) %}selected{% endif %}>{{ nature.name }}</option>{% endfor %}</select><select name="final_ledger_name" class="w-full rounded-lg border border-slate-300 bg-white px-2 py-2 text-sm"><option value="">No mapped ledger yet</option>{% for nature_id, maps in mappings_by_nature.items() %}{% for m in maps %}<option value="{{ m.ledger_name }}" {% if row.final_ledger_name==m.ledger_name or (not row.final_ledger_name and row.suggested_ledger_name==m.ledger_name) %}selected{% endif %}>{{ m.ledger_name }}</option>{% endfor %}{% endfor %}</select><button class="w-full rounded-lg bg-brand-600 px-3 py-2 text-sm font-semibold text-white">{{ 'Update Review' if row.review_status=='reviewed' else 'Confirm / Correct' }}</button></form></td></tr>
|
||||||
|
{% else %}<tr><td colspan="7" class="px-4 py-10 text-center text-slate-500">No purchases match the selected filters.</td></tr>{% endfor %}</tbody></table></div>
|
||||||
|
{% if result.pages>1 %}<div class="flex items-center justify-between border-t border-slate-200 px-5 py-4"><span class="text-sm text-slate-500">Page {{ result.page }} of {{ result.pages }}</span><div class="flex gap-2">{% if prev_url %}<a href="{{ prev_url }}" class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Previous</a>{% endif %}{% if next_url %}<a href="{{ next_url }}" class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Next</a>{% endif %}</div></div>{% endif %}</section>
|
||||||
|
</form>
|
||||||
|
<script>(function(){const a=document.getElementById('select-high');if(!a)return;a.addEventListener('change',function(){document.querySelectorAll('.high-select').forEach(function(b){b.checked=a.checked;});});})();</script>
|
||||||
|
{% endif %}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
{% if selected_client %}<a href="/tools/accounting/ledger-learning?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Ledger Learning</a>{% endif %}
|
{% if selected_client %}<a href="/tools/accounting/ledger-learning?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Ledger Learning</a>{% endif %}
|
||||||
{% if selected_client %}<a href="/tools/accounting/gstr2b?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">GSTR-2B Intelligence</a>{% endif %}
|
{% if selected_client %}<a href="/tools/accounting/gstr2b?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">GSTR-2B Intelligence</a>{% endif %}
|
||||||
{% if selected_client %}<a href="/tools/accounting/purchase-enrichment?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">E-Invoice / E-Way Bill</a>{% endif %}
|
{% if selected_client %}<a href="/tools/accounting/purchase-enrichment?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">E-Invoice / E-Way Bill</a>{% endif %}
|
||||||
|
{% if selected_client %}<a href="/tools/accounting/purchase-review?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Purchase Review</a>{% endif %}
|
||||||
{% if selected_client %}<a href="/tools/tally/depreciation?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Depreciation (IT)</a>{% endif %}
|
{% if selected_client %}<a href="/tools/tally/depreciation?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Depreciation (IT)</a>{% endif %}
|
||||||
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% else %}?refresh=1{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Refresh Tally Companies</a>
|
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% else %}?refresh=1{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Refresh Tally Companies</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from app.modules.accounting.historical_learning_ui import router as accounting_h
|
|||||||
from app.modules.accounting.ledger_learning_ui import router as accounting_ledger_learning_ui_router
|
from app.modules.accounting.ledger_learning_ui import router as accounting_ledger_learning_ui_router
|
||||||
from app.modules.accounting.gstr2b_ui import router as accounting_gstr2b_ui_router
|
from app.modules.accounting.gstr2b_ui import router as accounting_gstr2b_ui_router
|
||||||
from app.modules.accounting.purchase_enrichment_ui import router as accounting_purchase_enrichment_ui_router
|
from app.modules.accounting.purchase_enrichment_ui import router as accounting_purchase_enrichment_ui_router
|
||||||
|
from app.modules.accounting.purchase_review_ui import router as accounting_purchase_review_ui_router
|
||||||
from app.modules.registrations.ui import router as registrations_ui_router
|
from app.modules.registrations.ui import router as registrations_ui_router
|
||||||
from app.modules.credential_vault.ui import router as credential_vault_ui_router
|
from app.modules.credential_vault.ui import router as credential_vault_ui_router
|
||||||
from app.modules.client_identity.ui import router as client_identity_ui_router
|
from app.modules.client_identity.ui import router as client_identity_ui_router
|
||||||
@@ -70,6 +71,7 @@ def mount_ui(app: FastAPI) -> None:
|
|||||||
app.include_router(accounting_ledger_learning_ui_router)
|
app.include_router(accounting_ledger_learning_ui_router)
|
||||||
app.include_router(accounting_gstr2b_ui_router)
|
app.include_router(accounting_gstr2b_ui_router)
|
||||||
app.include_router(accounting_purchase_enrichment_ui_router)
|
app.include_router(accounting_purchase_enrichment_ui_router)
|
||||||
|
app.include_router(accounting_purchase_review_ui_router)
|
||||||
app.include_router(work_tracker_ui_router)
|
app.include_router(work_tracker_ui_router)
|
||||||
app.include_router(billing_ui_router)
|
app.include_router(billing_ui_router)
|
||||||
app.include_router(platform_billing_ui_router)
|
app.include_router(platform_billing_ui_router)
|
||||||
|
|||||||
Reference in New Issue
Block a user