From 82771844fd92ab70075a85e301aa2f41664a7db3 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Sat, 22 Aug 2026 15:54:25 +0530 Subject: [PATCH] Add Phase 9 purchase review board --- .../accounting/purchase_review_service.py | 153 ++++++++++++++++++ app/modules/accounting/purchase_review_ui.py | 92 +++++++++++ .../templates/accounting/purchase_review.html | 44 +++++ .../templates/accounting/tally.html | 1 + app/ui/app.py | 2 + 5 files changed, 292 insertions(+) create mode 100644 app/modules/accounting/purchase_review_service.py create mode 100644 app/modules/accounting/purchase_review_ui.py create mode 100644 app/modules/accounting/templates/accounting/purchase_review.html diff --git a/app/modules/accounting/purchase_review_service.py b/app/modules/accounting/purchase_review_service.py new file mode 100644 index 0000000..9bcf5ac --- /dev/null +++ b/app/modules/accounting/purchase_review_service.py @@ -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 diff --git a/app/modules/accounting/purchase_review_ui.py b/app/modules/accounting/purchase_review_ui.py new file mode 100644 index 0000000..2db79f3 --- /dev/null +++ b/app/modules/accounting/purchase_review_ui.py @@ -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"] +
+

Tools · Accounting Intelligence

Purchase Review Board

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.

+ +
+ {% if message %}
{{ message }}
{% endif %} + {% if error %}
{{ error }}
{% endif %} +
+ + {% if selected_client %} + + + + + + + + {% endif %}
+
+ {% if selected_client and counts %} +
{% 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,'')] %}
{{ label }}
{{ value }}
{% endfor %}
+ {% if result %} + +

Purchase Queue

{{ result.total }} records · page {{ result.page }} of {{ result.pages }}

+
+ {% 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 %} + + + + + + + + {% else %}{% endfor %}
Supplier / InvoiceSourceTaxableSuggestionFinal TreatmentReview
{% if row.review_status!='reviewed' and suggested and row.suggested_confidence>=85 %}{% endif %}
{{ row.supplier_name or row.supplier_gstin }}
{{ row.supplier_gstin or '-' }}
{{ row.invoice_number }} · {{ row.invoice_date }}
GSTR-2B{% if enrich %}+{{ enrich }} enrichment{% endif %}
₹{{ '%.2f'|format(row.taxable_value) }}
Invoice ₹{{ '%.2f'|format(row.invoice_value) }}
{% if suggested %}
{{ suggested.name }}{{ row.suggested_confidence }}%
{{ row.suggested_ledger_name or 'No mapped Tally ledger' }}
{% if explanations.get(row.id) %}
Why?
    {% for x in explanations.get(row.id)[:5] %}
  • • {{ x }}
  • {% endfor %}
{% endif %}{% else %}Review required{% endif %}
{% if row.review_status=='reviewed' %}
Reviewed
{{ final.name if final else '-' }}
{{ row.final_ledger_name or 'No ledger' }}
{% else %}Awaiting review{% endif %}
{% for k in ['tally_guid','status','confidence','source','supplier','return_period','page','per_page'] %}{% endfor %}
No purchases match the selected filters.
+ {% if result.pages>1 %}
Page {{ result.page }} of {{ result.pages }}
{% if prev_url %}Previous{% endif %}{% if next_url %}Next{% endif %}
{% endif %}
+ + + {% endif %}{% endif %} + +{% endblock %} diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html index 818237a..afac422 100644 --- a/app/modules/accounting/templates/accounting/tally.html +++ b/app/modules/accounting/templates/accounting/tally.html @@ -18,6 +18,7 @@ {% if selected_client %}Ledger Learning{% endif %} {% if selected_client %}GSTR-2B Intelligence{% endif %} {% if selected_client %}E-Invoice / E-Way Bill{% endif %} + {% if selected_client %}Purchase Review{% endif %} {% if selected_client %}Depreciation (IT){% endif %} Refresh Tally Companies diff --git a/app/ui/app.py b/app/ui/app.py index ac3534a..46bbda0 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -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.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_review_ui import router as accounting_purchase_review_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.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_gstr2b_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(billing_ui_router) app.include_router(platform_billing_ui_router)