Files
arrr-erp/app/modules/accounting/purchase_review_service.py
T
2026-08-22 21:52:44 +05:30

213 lines
11 KiB
Python

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
from app.modules.accounting.ai_service import ai_assist_purchase as run_ai_assist_purchase, mark_review_outcome
from app.modules.accounting.internal_model_service import predict_purchase, record_prediction_review
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)
reviewed = review_purchase(db,row=row,final_nature_id=int(final_nature_id),final_ledger_name=(final_ledger_name or "").strip(),user_id=user_id)
mark_review_outcome(
db,
tenant_id=tenant_id,
source_type="gstr2b",
source_record_id=reviewed.id,
final_nature_id=reviewed.final_nature_id,
final_ledger_name=reviewed.final_ledger_name,
user_id=user_id,
)
record_prediction_review(
db,
tenant_id=tenant_id,
source_type="gstr2b",
source_record_id=reviewed.id,
final_nature_id=reviewed.final_nature_id,
user_id=user_id,
)
return reviewed
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 "")
reviewed = 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)
mark_review_outcome(
db,
tenant_id=tenant_id,
source_type="gstr2b",
source_record_id=reviewed.id,
final_nature_id=reviewed.final_nature_id,
final_ledger_name=reviewed.final_ledger_name,
user_id=user_id,
)
record_prediction_review(
db,
tenant_id=tenant_id,
source_type="gstr2b",
source_record_id=reviewed.id,
final_nature_id=reviewed.final_nature_id,
user_id=user_id,
)
completed+=1
return completed
def ai_assist_one(db, *, tenant_id, client_id, purchase_id, user_id):
row = db.execute(select(AccountingGSTR2BPurchase).where(
AccountingGSTR2BPurchase.id == int(purchase_id),
AccountingGSTR2BPurchase.tenant_id == int(tenant_id),
AccountingGSTR2BPurchase.client_id == int(client_id),
)).scalar_one_or_none()
if not row:
raise ValueError("Purchase record was not found.")
return run_ai_assist_purchase(db, row=row, user_id=user_id)
def internal_model_predict_one(db, *, tenant_id, client_id, purchase_id, force_shadow=True):
row = db.execute(select(AccountingGSTR2BPurchase).where(
AccountingGSTR2BPurchase.id == int(purchase_id),
AccountingGSTR2BPurchase.tenant_id == int(tenant_id),
AccountingGSTR2BPurchase.client_id == int(client_id),
)).scalar_one_or_none()
if not row:
raise ValueError("Purchase record was not found.")
return predict_purchase(db, row=row, force_shadow=force_shadow)