from __future__ import annotations from datetime import date, timedelta from urllib.parse import urlencode from fastapi import APIRouter, Form, Request 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.historical_learning_models import AccountingHistoricalLearningRun from app.modules.accounting.ledger_learning_service import available_tally_guids from app.modules.accounting.sales_learning_models import AccountingSalesHistoricalRun from app.modules.accounting.sales_learning_service import ( historical_sales_rows, ingest_completed_sales_run, learning_summary, sales_mappings, ) 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 from app.modules.documents.agent_jobs import enqueue_agent_job from app.modules.documents.models import ERPWorkstationAgent router = APIRouter( prefix="/tools/accounting/sales-learning", tags=["accounting-sales-learning-ui"], ) def _go(client_id=0, message="", error=""): q = {} if client_id: q["client_id"] = client_id if message: q["message"] = message[:300] if error: q["error"] = error[:300] return RedirectResponse( "/tools/accounting/sales-learning" + ("?" + urlencode(q) if q else ""), status_code=303, ) def _workstations(db, tenant_id: int): return list(db.execute( select(ERPWorkstationAgent).where( ERPWorkstationAgent.tenant_id == int(tenant_id), ERPWorkstationAgent.is_active.is_(True), ERPWorkstationAgent.tally_connected.is_(True), ).order_by(ERPWorkstationAgent.machine_name) ).scalars().all()) @router.get("") def page( request: Request, client_id: int | None = None, message: str = "", error: str = "", ): db = CommonSessionLocal() try: user, denied = _require_partner(request, db, "accounting.learning.view") if denied: return denied clients, scope = _visible_clients(db, request, user) selected = next( (row for row in clients if client_id and int(row.id) == int(client_id)), None, ) mappings = [] evidence = [] summary = {"mapping_count": 0, "historical_rows": 0, "review_confirmed": 0, "high_confidence": 0} guids = [] workstations = [] runs = [] if selected: # Synchronize latest sales evidence run states opportunistically. runs = list(db.execute( select(AccountingSalesHistoricalRun).where( AccountingSalesHistoricalRun.tenant_id == scope.tenant_id, AccountingSalesHistoricalRun.client_id == selected.id, ).order_by(AccountingSalesHistoricalRun.id.desc()).limit(20) ).scalars().all()) for run in runs: if run.status not in {"completed", "failed"}: ingest_completed_sales_run(db, run) mappings = sales_mappings(db, tenant_id=scope.tenant_id, client_id=selected.id) evidence = historical_sales_rows(db, tenant_id=scope.tenant_id, client_id=selected.id)[:200] summary = learning_summary(db, tenant_id=scope.tenant_id, client_id=selected.id) guids = available_tally_guids(db, scope.tenant_id, selected.id) workstations = _workstations(db, scope.tenant_id) return templates.TemplateResponse( "modules/accounting/templates/accounting/sales_learning.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": "Customer & Sales Ledger Intelligence", "clients": clients, "selected_client": selected, "mappings": mappings, "evidence": evidence, "summary": summary, "tally_guids": guids, "workstations": workstations, "runs": runs, "message": message, "error": error, }, ) finally: db.close() @router.post("/collect") def collect( request: Request, client_id: int = Form(...), workstation_id: int = Form(...), tally_guid: str = Form(...), date_from: str = Form(""), date_to: str = Form(""), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) db = CommonSessionLocal() try: user, denied = _require_partner(request, db, "accounting.learning.manage") if denied: return denied client, _, scope = _find_visible_client(db, request, user, client_id) if not client: return _go(error="Client is not visible.") workstation = db.get(ERPWorkstationAgent, int(workstation_id)) if ( not workstation or int(workstation.tenant_id) != int(scope.tenant_id) or not workstation.is_active or not workstation.tally_connected ): raise ValueError("Selected workstation is not available with Tally connected.") guid_options = dict(available_tally_guids(db, scope.tenant_id, client.id)) if tally_guid not in guid_options: raise ValueError("Selected Tally company is not mapped to this client.") if not date_from: date_from = (date.today() - timedelta(days=730)).isoformat() if not date_to: date_to = date.today().isoformat() payload = { "tenant_id": scope.tenant_id, "client_id": client.id, "tally_guid": tally_guid, "date_from": date_from, "date_to": date_to, "voucher_scope": "sales", } job = enqueue_agent_job( db, workstation_agent_id=workstation.id, action="accounting_historical_evidence", payload=payload, priority=6, max_attempts=2, created_by_user_id=user.id, ) run = AccountingSalesHistoricalRun( tenant_id=scope.tenant_id, client_id=client.id, tally_guid=tally_guid, company_name=guid_options.get(tally_guid, ""), workstation_agent_id=workstation.id, agent_job_id=job.id, date_from=date_from, date_to=date_to, status="queued", requested_by_user_id=user.id, ) db.add(run) db.commit() return _go( client.id, message="Historical Tally Sales evidence collection queued.", ) except Exception as exc: db.rollback() return _go(client_id, error=str(exc)) finally: db.close()