diff --git a/app/modules/accounting/historical_learning_ui.py b/app/modules/accounting/historical_learning_ui.py index b498d7a..402c48e 100644 --- a/app/modules/accounting/historical_learning_ui.py +++ b/app/modules/accounting/historical_learning_ui.py @@ -13,6 +13,7 @@ from app.core.templating import templates from app.modules.accounting.historical_learning_models import AccountingHistoricalLearningRun from app.modules.accounting.historical_learning_service import active_natures, classifiable_ledgers, evidence_rows, ingest_completed_run, ledger_mappings, party_suggestions, remove_ledger_mapping, save_ledger_mapping from app.modules.accounting.ui import _accounting_storage_payload, _find_visible_client, _require_partner, _visible_clients +from app.modules.accounting.historical_sync_support import eligible_tally_workstations, resolve_workstation_company, workstation_companies from app.modules.core.rbac.deps import get_user_permissions, get_user_roles from app.modules.core.rbac.permission_guard import require_permission from app.modules.documents.agent_jobs import enqueue_agent_job @@ -31,17 +32,6 @@ def _fy_dates(): return date(y,4,1).isoformat(), today.isoformat() -def _companies(workstation): - try: rows=json.loads(workstation.tally_companies_json or "[]") - except Exception: rows=[] - return rows if isinstance(rows,list) else [] - - -def _workstations(db, tenant_id: int, branch_id: int | None): - stmt=select(ERPWorkstationAgent).where(ERPWorkstationAgent.tenant_id==tenant_id, ERPWorkstationAgent.is_active.is_(True)) - if branch_id is not None: stmt=stmt.where(ERPWorkstationAgent.branch_id==branch_id) - return list(db.execute(stmt.order_by(ERPWorkstationAgent.last_seen_at_utc.desc())).scalars().all()) - def _sync_runs(db, tenant_id: int, client_id: int): runs=list(db.execute(select(AccountingHistoricalLearningRun).where(AccountingHistoricalLearningRun.tenant_id==tenant_id, AccountingHistoricalLearningRun.client_id==client_id).order_by(AccountingHistoricalLearningRun.id.desc()).limit(10)).scalars().all()) @@ -59,7 +49,7 @@ def historical_learning(request: Request, client_id: int | None=None, collected: 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) runs=[]; evid=[]; ledgers=[]; suggestions=[]; mappings=[] - workstations=_workstations(db, scope.tenant_id, scope.branch_id) + workstations=eligible_tally_workstations(db, scope.tenant_id, scope.branch_id) if selected: runs=_sync_runs(db,scope.tenant_id,selected.id) evid=evidence_rows(db,scope.tenant_id,selected.id) @@ -69,7 +59,7 @@ def historical_learning(request: Request, client_id: int | None=None, collected: df,dt=_fy_dates() return templates.TemplateResponse("modules/accounting/templates/accounting/historical_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":"Historical Tally Learning","clients":clients,"selected_client":selected,"workstations":workstations,"workstation_companies":{w.id:_companies(w) for w in workstations}, + "title":"Historical Tally Learning","clients":clients,"selected_client":selected,"workstations":workstations,"workstation_companies":{w.id:workstation_companies(w) for w in workstations}, "runs":runs,"evidence":evid,"classifiable_ledgers":ledgers,"suggestions":suggestions,"mappings":mappings,"natures":active_natures(db,scope.tenant_id), "date_from":df,"date_to":dt,"collected":bool(collected),"mapped":bool(mapped),"error":error, }) @@ -84,14 +74,29 @@ def collect(request: Request, client_id: int=Form(...), workstation_id: int=Form if response:return response client,_,scope=_find_visible_client(db,request,user,client_id) if not client:return _denied() - workstation=db.get(ERPWorkstationAgent,workstation_id) - if not workstation or not workstation.is_active or workstation.tenant_id!=scope.tenant_id or (scope.branch_id is not None and workstation.branch_id!=scope.branch_id): return _denied() guid=str(tally_guid or "").strip() - if not guid: return RedirectResponse(url=f"/tools/accounting/historical-learning?client_id={client_id}&error=company_required",status_code=303) - payload=_accounting_storage_payload(client); payload.update({"tenant_id":scope.tenant_id,"requested_by_user_id":user.id,"tally_guid":guid,"date_from":date_from,"date_to":date_to,"voucher_scope":"purchase"}) + if not guid: + return RedirectResponse(url=f"/tools/accounting/historical-learning?client_id={client_id}&error=company_required",status_code=303) + workstation, company = resolve_workstation_company( + db, + workstation_id=workstation_id, + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + tally_guid=guid, + ) + payload=_accounting_storage_payload(client); payload.update({ + "tenant_id":scope.tenant_id, + "requested_by_user_id":user.id, + "tally_guid":guid, + "company_name":str(company.get("name") or company_name or "").strip(), + "date_from":date_from, + "date_to":date_to, + "voucher_scope":"purchase", + "refresh_transactions":True, + }) 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) db.flush() - run=AccountingHistoricalLearningRun(tenant_id=scope.tenant_id,client_id=client.id,workstation_agent_id=workstation.id,agent_job_id=job.id,tally_guid=guid,company_name=company_name or "",date_from=date_from or None,date_to=date_to or None,status="queued",requested_by_user_id=user.id) + run=AccountingHistoricalLearningRun(tenant_id=scope.tenant_id,client_id=client.id,workstation_agent_id=workstation.id,agent_job_id=job.id,tally_guid=guid,company_name=str(company.get("name") or company_name or ""),date_from=date_from or None,date_to=date_to or None,status="queued",requested_by_user_id=user.id) db.add(run); db.commit() return RedirectResponse(url=f"/tools/accounting/historical-learning?client_id={client_id}&collected=1",status_code=303) except Exception as exc: diff --git a/app/modules/accounting/historical_sync_support.py b/app/modules/accounting/historical_sync_support.py new file mode 100644 index 0000000..4411df6 --- /dev/null +++ b/app/modules/accounting/historical_sync_support.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select + +from app.modules.documents.models import ERPWorkstationAgent + + +ONLINE_WINDOW = timedelta(minutes=5) + + +def workstation_companies(workstation) -> list[dict]: + try: + rows = json.loads(workstation.tally_companies_json or "[]") + except Exception: + rows = [] + if not isinstance(rows, list): + return [] + + result = [] + seen = set() + for row in rows: + if not isinstance(row, dict): + continue + guid = str(row.get("guid") or "").strip() + name = str(row.get("name") or "").strip() + if not guid or guid in seen: + continue + seen.add(guid) + result.append( + { + "guid": guid, + "name": name, + "gstin": str(row.get("gstin") or "").strip(), + "gstins": list(row.get("gstins") or []), + } + ) + return result + + +def workstation_is_recent(workstation) -> bool: + seen = getattr(workstation, "last_seen_at_utc", None) + if not seen: + return False + if seen.tzinfo is None: + seen = seen.replace(tzinfo=timezone.utc) + return datetime.now(timezone.utc) - seen <= ONLINE_WINDOW + + +def eligible_tally_workstations(db, tenant_id: int, branch_id: int | None = None): + stmt = select(ERPWorkstationAgent).where( + ERPWorkstationAgent.tenant_id == int(tenant_id), + ERPWorkstationAgent.is_active.is_(True), + ERPWorkstationAgent.tally_connected.is_(True), + ) + if branch_id is not None: + stmt = stmt.where(ERPWorkstationAgent.branch_id == int(branch_id)) + + rows = list( + db.execute( + stmt.order_by( + ERPWorkstationAgent.last_seen_at_utc.desc(), + ERPWorkstationAgent.machine_name.asc(), + ) + ).scalars().all() + ) + return [ + row + for row in rows + if workstation_is_recent(row) and workstation_companies(row) + ] + + +def resolve_workstation_company( + db, + *, + workstation_id: int, + tenant_id: int, + branch_id: int | None, + tally_guid: str, +): + workstation = db.get(ERPWorkstationAgent, int(workstation_id)) + if ( + not workstation + or int(workstation.tenant_id) != int(tenant_id) + or not workstation.is_active + or not workstation.tally_connected + or not workstation_is_recent(workstation) + or ( + branch_id is not None + and int(workstation.branch_id or 0) != int(branch_id) + ) + ): + raise ValueError( + "Selected ERP Local Agent workstation is offline, stale, or not connected to Tally." + ) + + guid = str(tally_guid or "").strip() + company = next( + ( + row + for row in workstation_companies(workstation) + if str(row.get("guid") or "").strip() == guid + ), + None, + ) + if not company: + raise ValueError( + "Selected Tally company is not currently advertised by this workstation. " + "Open the company in TallyPrime and allow the Local Agent to refresh." + ) + return workstation, company diff --git a/app/modules/accounting/sales_learning_ui.py b/app/modules/accounting/sales_learning_ui.py index 165a9c2..31425ee 100644 --- a/app/modules/accounting/sales_learning_ui.py +++ b/app/modules/accounting/sales_learning_ui.py @@ -11,7 +11,6 @@ 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, @@ -19,7 +18,8 @@ from app.modules.accounting.sales_learning_service import ( learning_summary, sales_mappings, ) -from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients +from app.modules.accounting.ui import _accounting_storage_payload, _find_visible_client, _require_partner, _visible_clients +from app.modules.accounting.historical_sync_support import eligible_tally_workstations, resolve_workstation_company, workstation_companies 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 @@ -44,15 +44,6 @@ def _go(client_id=0, message="", error=""): ) -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( @@ -76,7 +67,6 @@ def page( mappings = [] evidence = [] summary = {"mapping_count": 0, "historical_rows": 0, "review_confirmed": 0, "high_confidence": 0} - guids = [] workstations = [] runs = [] @@ -95,8 +85,7 @@ def page( 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) + workstations = eligible_tally_workstations(db, scope.tenant_id, scope.branch_id) return templates.TemplateResponse( "modules/accounting/templates/accounting/sales_learning.html", @@ -112,8 +101,8 @@ def page( "mappings": mappings, "evidence": evidence, "summary": summary, - "tally_guids": guids, "workstations": workstations, + "workstation_companies": {w.id: workstation_companies(w) for w in workstations}, "runs": runs, "message": message, "error": error, @@ -144,18 +133,13 @@ def collect( 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.") + workstation, company = resolve_workstation_company( + db, + workstation_id=workstation_id, + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + tally_guid=tally_guid, + ) if not date_from: date_from = (date.today() - timedelta(days=730)).isoformat() @@ -163,12 +147,15 @@ def collect( date_to = date.today().isoformat() payload = { + **_accounting_storage_payload(client), "tenant_id": scope.tenant_id, - "client_id": client.id, + "requested_by_user_id": user.id, "tally_guid": tally_guid, + "company_name": str(company.get("name") or "").strip(), "date_from": date_from, "date_to": date_to, "voucher_scope": "sales", + "refresh_transactions": True, } job = enqueue_agent_job( db, @@ -184,7 +171,7 @@ def collect( tenant_id=scope.tenant_id, client_id=client.id, tally_guid=tally_guid, - company_name=guid_options.get(tally_guid, ""), + company_name=str(company.get("name") or ""), workstation_agent_id=workstation.id, agent_job_id=job.id, date_from=date_from, diff --git a/app/modules/accounting/templates/accounting/historical_learning.html b/app/modules/accounting/templates/accounting/historical_learning.html index 341e1fb..9a9c00f 100644 --- a/app/modules/accounting/templates/accounting/historical_learning.html +++ b/app/modules/accounting/templates/accounting/historical_learning.html @@ -2,7 +2,7 @@ {% block content %}
Tools · Accounting · Phase 5
Uses synced Tally purchase history as evidence. No voucher is created or altered in this phase.
Tools · Accounting · Phase 5
Refreshes the selected client's Purchase vouchers directly from the currently connected Tally company, then builds historical evidence. The operation is read-only; no voucher is created or altered.
Select the workstation that holds this client's synced .act data. The agent returns neutral ledger-history facts; classification stays on the ERP server.
+Select the connected ERP Local Agent workstation and currently open mapped Tally company. The agent refreshes the selected period read-only before returning neutral ledger-history facts; classification stays on the ERP server.
+ {% if not workstations %} +