diff --git a/app/modules/accounting/historical_learning_ui.py b/app/modules/accounting/historical_learning_ui.py index 402c48e..fde0e35 100644 --- a/app/modules/accounting/historical_learning_ui.py +++ b/app/modules/accounting/historical_learning_ui.py @@ -4,7 +4,7 @@ import json from datetime import date, datetime, timezone from fastapi import APIRouter, Form, Request -from fastapi.responses import RedirectResponse +from fastapi.responses import JSONResponse, RedirectResponse from sqlalchemy import select from app.core.db.common import CommonSessionLocal @@ -14,6 +14,7 @@ from app.modules.accounting.historical_learning_models import AccountingHistoric 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.accounting.historical_progress import historical_run_status 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 @@ -60,7 +61,7 @@ def historical_learning(request: Request, client_id: int | None=None, collected: 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: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), + "runs":runs,"latest_run":runs[0] if runs else None,"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, }) finally: db.close() @@ -128,3 +129,30 @@ def unmap_ledger(request: Request, client_id: int=Form(...), mapping_id: int=For remove_ledger_mapping(db,tenant_id=scope.tenant_id,client_id=client.id,mapping_id=mapping_id) return RedirectResponse(url=f"/tools/accounting/historical-learning?client_id={client_id}",status_code=303) finally: db.close() + + +@router.get("/run/{run_id}/status") +def run_status(request: Request, run_id: int): + db = CommonSessionLocal() + try: + user, response = _require_partner(request, db, "accounting.learning.view") + if response: + return JSONResponse({"ok": False, "error": "Access denied."}, status_code=403) + + clients, scope = _visible_clients(db, request, user) + visible_client_ids = {int(c.id) for c in clients} + + run = db.get(AccountingHistoricalLearningRun, int(run_id)) + if ( + not run + or int(run.tenant_id) != int(scope.tenant_id) + or int(run.client_id) not in visible_client_ids + ): + return JSONResponse({"ok": False, "error": "Historical Purchase run not found."}, status_code=404) + + if run.status not in {"completed", "failed"}: + ingest_completed_run(db, run) + + return JSONResponse({"ok": True, **historical_run_status(db, run)}) + finally: + db.close() diff --git a/app/modules/accounting/historical_progress.py b/app/modules/accounting/historical_progress.py new file mode 100644 index 0000000..4aef8e2 --- /dev/null +++ b/app/modules/accounting/historical_progress.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from app.modules.documents.models import ERPAgentJob, ERPWorkstationAgent + + +def _utcnow(): + return datetime.now(timezone.utc) + + +def _iso(value): + return value.isoformat() if value else None + + +def _elapsed_seconds(started): + if not started: + return 0 + try: + now = _utcnow() + value = started + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return max(0, int((now - value).total_seconds())) + except Exception: + return 0 + + +def historical_run_status(db, run): + job = db.get(ERPAgentJob, run.agent_job_id) if run and run.agent_job_id else None + workstation = db.get(ERPWorkstationAgent, run.workstation_agent_id) if run and run.workstation_agent_id else None + + job_status = str(job.status if job else run.status or "queued").lower() + run_status = str(run.status or job_status).lower() + + if run_status == "completed": + stage = "completed" + label = "Completed" + terminal = True + ok = True + elif run_status == "failed": + stage = "failed" + label = "Failed" + terminal = True + ok = False + elif job_status == "claimed": + stage = "processing" + label = "Processing on workstation" + terminal = False + ok = None + elif job_status == "succeeded": + stage = "ingesting" + label = "Result received; updating ERP evidence" + terminal = False + ok = None + elif job_status == "failed": + stage = "failed" + label = "Failed" + terminal = True + ok = False + elif job_status == "cancelled": + stage = "failed" + label = "Cancelled" + terminal = True + ok = False + else: + stage = "queued" + label = "Queued for workstation" + terminal = False + ok = None + + return { + "run_id": int(run.id), + "job_id": int(job.id) if job else None, + "status": run_status, + "job_status": job_status, + "stage": stage, + "label": label, + "terminal": terminal, + "ok": ok, + "workstation": ( + str(getattr(workstation, "machine_name", "") or "").strip() + or str(getattr(workstation, "agent_instance_id", "") or "").strip() + or "ERP Local Agent" + ) if workstation else "ERP Local Agent", + "company_name": str(run.company_name or run.tally_guid or ""), + "date_from": str(run.date_from or ""), + "date_to": str(run.date_to or ""), + "evidence_rows": int(run.evidence_rows or 0), + "attempts": int(job.attempts or 0) if job else 0, + "max_attempts": int(job.max_attempts or 0) if job else 0, + "error": str(run.error_message or (job.last_error if job else "") or ""), + "created_at": _iso(run.created_at_utc), + "claimed_at": _iso(job.claimed_at_utc) if job else None, + "completed_at": _iso(run.completed_at_utc or (job.completed_at_utc if job else None)), + "elapsed_seconds": _elapsed_seconds(run.created_at_utc), + } diff --git a/app/modules/accounting/sales_learning_ui.py b/app/modules/accounting/sales_learning_ui.py index 31425ee..3c7932d 100644 --- a/app/modules/accounting/sales_learning_ui.py +++ b/app/modules/accounting/sales_learning_ui.py @@ -4,7 +4,7 @@ from datetime import date, timedelta from urllib.parse import urlencode from fastapi import APIRouter, Form, Request -from fastapi.responses import RedirectResponse +from fastapi.responses import JSONResponse, RedirectResponse from sqlalchemy import select from app.core.db.common import CommonSessionLocal @@ -20,6 +20,7 @@ from app.modules.accounting.sales_learning_service import ( ) 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.accounting.historical_progress import historical_run_status 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 @@ -104,6 +105,7 @@ def page( "workstations": workstations, "workstation_companies": {w.id: workstation_companies(w) for w in workstations}, "runs": runs, + "latest_run": runs[0] if runs else None, "message": message, "error": error, }, @@ -191,3 +193,30 @@ def collect( return _go(client_id, error=str(exc)) finally: db.close() + + +@router.get("/run/{run_id}/status") +def run_status(request: Request, run_id: int): + db = CommonSessionLocal() + try: + user, denied = _require_partner(request, db, "accounting.learning.view") + if denied: + return JSONResponse({"ok": False, "error": "Access denied."}, status_code=403) + + clients, scope = _visible_clients(db, request, user) + visible_client_ids = {int(c.id) for c in clients} + + run = db.get(AccountingSalesHistoricalRun, int(run_id)) + if ( + not run + or int(run.tenant_id) != int(scope.tenant_id) + or int(run.client_id) not in visible_client_ids + ): + return JSONResponse({"ok": False, "error": "Historical Sales run not found."}, status_code=404) + + if run.status not in {"completed", "failed"}: + ingest_completed_sales_run(db, run) + + return JSONResponse({"ok": True, **historical_run_status(db, run)}) + finally: + db.close() diff --git a/app/modules/accounting/templates/accounting/historical_learning.html b/app/modules/accounting/templates/accounting/historical_learning.html index 9a9c00f..86726f2 100644 --- a/app/modules/accounting/templates/accounting/historical_learning.html +++ b/app/modules/accounting/templates/accounting/historical_learning.html @@ -17,6 +17,25 @@ {% if selected_client %} + {% if latest_run %} +
+
+
+
Latest Historical Purchase Run
+
{{ latest_run.status|replace('_',' ')|title }}
+
{{ latest_run.company_name or latest_run.tally_guid }}
+
+
Run #{{ latest_run.id }}
+
+
+
+
+ +
+ {% endif %} +

Collect Historical Purchase Evidence

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.

@@ -59,4 +78,80 @@ + + + {% endblock %} diff --git a/app/modules/accounting/templates/accounting/sales_learning.html b/app/modules/accounting/templates/accounting/sales_learning.html index dfd4fca..517aa51 100644 --- a/app/modules/accounting/templates/accounting/sales_learning.html +++ b/app/modules/accounting/templates/accounting/sales_learning.html @@ -32,6 +32,26 @@ {% endfor %}
+ {% if latest_run %} +
+
+
+
Latest Historical Sales Run
+
{{ latest_run.status|replace('_',' ')|title }}
+
{{ latest_run.company_name or latest_run.tally_guid }}
+
+
Run #{{ latest_run.id }}
+
+
+
+
+ +
+ {% endif %} +

Collect Historical Sales Evidence from Tally

Read-only. The selected workstation refreshes Sales vouchers from the currently open mapped Tally company for the chosen period, then stores neutral historical evidence. It does not create or alter Tally vouchers.

@@ -109,4 +129,80 @@ syncSelection(); })(); + + + {% endblock %}