Fix live Purchase and Sales historical Tally sync
This commit is contained in:
@@ -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_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.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.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.deps import get_user_permissions, get_user_roles
|
||||||
from app.modules.core.rbac.permission_guard import require_permission
|
from app.modules.core.rbac.permission_guard import require_permission
|
||||||
from app.modules.documents.agent_jobs import enqueue_agent_job
|
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()
|
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):
|
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())
|
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)
|
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)
|
selected=next((c for c in clients if client_id and int(c.id)==int(client_id)),None)
|
||||||
runs=[]; evid=[]; ledgers=[]; suggestions=[]; mappings=[]
|
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:
|
if selected:
|
||||||
runs=_sync_runs(db,scope.tenant_id,selected.id)
|
runs=_sync_runs(db,scope.tenant_id,selected.id)
|
||||||
evid=evidence_rows(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()
|
df,dt=_fy_dates()
|
||||||
return templates.TemplateResponse("modules/accounting/templates/accounting/historical_learning.html", {
|
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),
|
"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),
|
"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,
|
"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
|
if response:return response
|
||||||
client,_,scope=_find_visible_client(db,request,user,client_id)
|
client,_,scope=_find_visible_client(db,request,user,client_id)
|
||||||
if not client:return _denied()
|
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()
|
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)
|
if not guid:
|
||||||
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"})
|
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)
|
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()
|
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()
|
db.add(run); db.commit()
|
||||||
return RedirectResponse(url=f"/tools/accounting/historical-learning?client_id={client_id}&collected=1",status_code=303)
|
return RedirectResponse(url=f"/tools/accounting/historical-learning?client_id={client_id}&collected=1",status_code=303)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.security.csrf import get_or_create_csrf_token, validate_csrf
|
||||||
from app.core.templating import templates
|
from app.core.templating import templates
|
||||||
from app.modules.accounting.historical_learning_models import AccountingHistoricalLearningRun
|
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_models import AccountingSalesHistoricalRun
|
||||||
from app.modules.accounting.sales_learning_service import (
|
from app.modules.accounting.sales_learning_service import (
|
||||||
historical_sales_rows,
|
historical_sales_rows,
|
||||||
@@ -19,7 +18,8 @@ from app.modules.accounting.sales_learning_service import (
|
|||||||
learning_summary,
|
learning_summary,
|
||||||
sales_mappings,
|
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.core.rbac.deps import get_user_permissions, get_user_roles
|
||||||
from app.modules.documents.agent_jobs import enqueue_agent_job
|
from app.modules.documents.agent_jobs import enqueue_agent_job
|
||||||
from app.modules.documents.models import ERPWorkstationAgent
|
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("")
|
@router.get("")
|
||||||
def page(
|
def page(
|
||||||
@@ -76,7 +67,6 @@ def page(
|
|||||||
mappings = []
|
mappings = []
|
||||||
evidence = []
|
evidence = []
|
||||||
summary = {"mapping_count": 0, "historical_rows": 0, "review_confirmed": 0, "high_confidence": 0}
|
summary = {"mapping_count": 0, "historical_rows": 0, "review_confirmed": 0, "high_confidence": 0}
|
||||||
guids = []
|
|
||||||
workstations = []
|
workstations = []
|
||||||
runs = []
|
runs = []
|
||||||
|
|
||||||
@@ -95,8 +85,7 @@ def page(
|
|||||||
mappings = sales_mappings(db, tenant_id=scope.tenant_id, client_id=selected.id)
|
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]
|
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)
|
summary = learning_summary(db, tenant_id=scope.tenant_id, client_id=selected.id)
|
||||||
guids = available_tally_guids(db, scope.tenant_id, selected.id)
|
workstations = eligible_tally_workstations(db, scope.tenant_id, scope.branch_id)
|
||||||
workstations = _workstations(db, scope.tenant_id)
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"modules/accounting/templates/accounting/sales_learning.html",
|
"modules/accounting/templates/accounting/sales_learning.html",
|
||||||
@@ -112,8 +101,8 @@ def page(
|
|||||||
"mappings": mappings,
|
"mappings": mappings,
|
||||||
"evidence": evidence,
|
"evidence": evidence,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"tally_guids": guids,
|
|
||||||
"workstations": workstations,
|
"workstations": workstations,
|
||||||
|
"workstation_companies": {w.id: workstation_companies(w) for w in workstations},
|
||||||
"runs": runs,
|
"runs": runs,
|
||||||
"message": message,
|
"message": message,
|
||||||
"error": error,
|
"error": error,
|
||||||
@@ -144,18 +133,13 @@ def collect(
|
|||||||
if not client:
|
if not client:
|
||||||
return _go(error="Client is not visible.")
|
return _go(error="Client is not visible.")
|
||||||
|
|
||||||
workstation = db.get(ERPWorkstationAgent, int(workstation_id))
|
workstation, company = resolve_workstation_company(
|
||||||
if (
|
db,
|
||||||
not workstation
|
workstation_id=workstation_id,
|
||||||
or int(workstation.tenant_id) != int(scope.tenant_id)
|
tenant_id=scope.tenant_id,
|
||||||
or not workstation.is_active
|
branch_id=scope.branch_id,
|
||||||
or not workstation.tally_connected
|
tally_guid=tally_guid,
|
||||||
):
|
)
|
||||||
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:
|
if not date_from:
|
||||||
date_from = (date.today() - timedelta(days=730)).isoformat()
|
date_from = (date.today() - timedelta(days=730)).isoformat()
|
||||||
@@ -163,12 +147,15 @@ def collect(
|
|||||||
date_to = date.today().isoformat()
|
date_to = date.today().isoformat()
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
|
**_accounting_storage_payload(client),
|
||||||
"tenant_id": scope.tenant_id,
|
"tenant_id": scope.tenant_id,
|
||||||
"client_id": client.id,
|
"requested_by_user_id": user.id,
|
||||||
"tally_guid": tally_guid,
|
"tally_guid": tally_guid,
|
||||||
|
"company_name": str(company.get("name") or "").strip(),
|
||||||
"date_from": date_from,
|
"date_from": date_from,
|
||||||
"date_to": date_to,
|
"date_to": date_to,
|
||||||
"voucher_scope": "sales",
|
"voucher_scope": "sales",
|
||||||
|
"refresh_transactions": True,
|
||||||
}
|
}
|
||||||
job = enqueue_agent_job(
|
job = enqueue_agent_job(
|
||||||
db,
|
db,
|
||||||
@@ -184,7 +171,7 @@ def collect(
|
|||||||
tenant_id=scope.tenant_id,
|
tenant_id=scope.tenant_id,
|
||||||
client_id=client.id,
|
client_id=client.id,
|
||||||
tally_guid=tally_guid,
|
tally_guid=tally_guid,
|
||||||
company_name=guid_options.get(tally_guid, ""),
|
company_name=str(company.get("name") or ""),
|
||||||
workstation_agent_id=workstation.id,
|
workstation_agent_id=workstation.id,
|
||||||
agent_job_id=job.id,
|
agent_job_id=job.id,
|
||||||
date_from=date_from,
|
date_from=date_from,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div><p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting · Phase 5</p><h1 class="mt-1 text-2xl font-semibold text-slate-900">Historical Tally Learning</h1><p class="mt-1 text-sm text-slate-500">Uses synced Tally purchase history as evidence. No voucher is created or altered in this phase.</p></div>
|
<div><p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting · Phase 5</p><h1 class="mt-1 text-2xl font-semibold text-slate-900">Historical Tally Learning</h1><p class="mt-1 text-sm text-slate-500">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.</p></div>
|
||||||
<div class="flex gap-2"><a href="/tools/accounting/taxonomy" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Accounting Taxonomy</a><a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Back to Tally</a></div>
|
<div class="flex gap-2"><a href="/tools/accounting/taxonomy" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Accounting Taxonomy</a><a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Back to Tally</a></div>
|
||||||
</div>
|
</div>
|
||||||
{% if collected %}<div class="rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">Historical evidence job queued. This page will ingest the result after the selected workstation completes it.</div>{% endif %}
|
{% if collected %}<div class="rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">Historical evidence job queued. This page will ingest the result after the selected workstation completes it.</div>{% endif %}
|
||||||
@@ -19,7 +19,10 @@
|
|||||||
{% if selected_client %}
|
{% if selected_client %}
|
||||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
<h2 class="text-lg font-semibold text-slate-900">Collect Historical Purchase Evidence</h2>
|
<h2 class="text-lg font-semibold text-slate-900">Collect Historical Purchase Evidence</h2>
|
||||||
<p class="mt-1 text-sm text-slate-500">Select the workstation that holds this client's synced .act data. The agent returns neutral ledger-history facts; classification stays on the ERP server.</p>
|
<p class="mt-1 text-sm text-slate-500">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.</p>
|
||||||
|
{% if not workstations %}
|
||||||
|
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">No recent ERP Local Agent workstation with Tally connected is advertising a company for this branch. Update/start the Local Agent, open the mapped company in TallyPrime, and refresh this page.</div>
|
||||||
|
{% endif %}
|
||||||
<form method="post" action="/tools/accounting/historical-learning/collect" class="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
<form method="post" action="/tools/accounting/historical-learning/collect" class="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||||
<label class="text-sm font-medium text-slate-700 xl:col-span-2">Workstation / Tally Company<select id="wsCompany" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" required><option value="">Select</option>{% for w in workstations %}{% for company in workstation_companies.get(w.id,[]) %}<option value="{{ w.id }}|{{ company.get('guid','') }}|{{ company.get('name','') }}">{{ w.machine_name }} · {{ company.get('name','') }}{% if not w.tally_connected %} (Tally not connected){% endif %}</option>{% endfor %}{% endfor %}</select></label>
|
<label class="text-sm font-medium text-slate-700 xl:col-span-2">Workstation / Tally Company<select id="wsCompany" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" required><option value="">Select</option>{% for w in workstations %}{% for company in workstation_companies.get(w.id,[]) %}<option value="{{ w.id }}|{{ company.get('guid','') }}|{{ company.get('name','') }}">{{ w.machine_name }} · {{ company.get('name','') }}{% if not w.tally_connected %} (Tally not connected){% endif %}</option>{% endfor %}{% endfor %}</select></label>
|
||||||
|
|||||||
@@ -34,22 +34,25 @@
|
|||||||
|
|
||||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
<h2 class="font-semibold">Collect Historical Sales Evidence from Tally</h2>
|
<h2 class="font-semibold">Collect Historical Sales Evidence from Tally</h2>
|
||||||
<p class="mt-1 text-sm text-slate-500">Read-only. Uses the existing durable Local Agent job queue and only Sales voucher history. It does not create or alter Tally vouchers.</p>
|
<p class="mt-1 text-sm text-slate-500">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.</p>
|
||||||
|
{% if not workstations %}
|
||||||
|
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">No recent ERP Local Agent workstation with Tally connected is advertising a company for this branch. Update/start the Local Agent, open the mapped company in TallyPrime, and refresh this page.</div>
|
||||||
|
{% endif %}
|
||||||
<form method="post" action="/tools/accounting/sales-learning/collect" class="mt-4 grid gap-3 md:grid-cols-5">
|
<form method="post" action="/tools/accounting/sales-learning/collect" class="mt-4 grid gap-3 md:grid-cols-5">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||||
<label class="text-xs font-semibold md:col-span-2">Mapped Tally Company
|
<label class="text-xs font-semibold md:col-span-3">Workstation / Currently Open Tally Company
|
||||||
<select name="tally_guid" required class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
|
<select id="salesWsCompany" required class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
|
||||||
<option value="">Select company</option>
|
<option value="">Select workstation and company</option>
|
||||||
{% for guid,name in tally_guids %}<option value="{{ guid }}">{{ name }}</option>{% endfor %}
|
{% for ws in workstations %}
|
||||||
</select>
|
{% for company in workstation_companies.get(ws.id, []) %}
|
||||||
</label>
|
<option value="{{ ws.id }}|{{ company.get('guid','') }}">{{ ws.machine_name }} · {{ company.get('name','') }}</option>
|
||||||
<label class="text-xs font-semibold">Workstation
|
{% endfor %}
|
||||||
<select name="workstation_id" required class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
|
{% endfor %}
|
||||||
<option value="">Select</option>
|
|
||||||
{% for ws in workstations %}<option value="{{ ws.id }}">{{ ws.machine_name }}</option>{% endfor %}
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<input type="hidden" name="workstation_id" id="salesWorkstationId">
|
||||||
|
<input type="hidden" name="tally_guid" id="salesTallyGuid">
|
||||||
<label class="text-xs font-semibold">From
|
<label class="text-xs font-semibold">From
|
||||||
<input type="date" name="date_from" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
|
<input type="date" name="date_from" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
|
||||||
</label>
|
</label>
|
||||||
@@ -89,4 +92,21 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const combined = document.getElementById("salesWsCompany");
|
||||||
|
const ws = document.getElementById("salesWorkstationId");
|
||||||
|
const guid = document.getElementById("salesTallyGuid");
|
||||||
|
if (!combined || !ws || !guid) return;
|
||||||
|
function syncSelection() {
|
||||||
|
const parts = (combined.value || "").split("|");
|
||||||
|
ws.value = parts[0] || "";
|
||||||
|
guid.value = parts[1] || "";
|
||||||
|
}
|
||||||
|
combined.addEventListener("change", syncSelection);
|
||||||
|
const form = combined.closest("form");
|
||||||
|
if (form) form.addEventListener("submit", syncSelection);
|
||||||
|
syncSelection();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import io
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
ERP_LOCAL_AGENT_VERSION = "1.15.0"
|
ERP_LOCAL_AGENT_VERSION = "1.15.1"
|
||||||
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
||||||
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
||||||
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
__version__ = "1.15.0"
|
__version__ = "1.15.1"
|
||||||
AGENT_NAME = "ERP Local Agent"
|
AGENT_NAME = "ERP Local Agent"
|
||||||
|
|||||||
@@ -216,15 +216,111 @@ class AgentCommandProcessor:
|
|||||||
return {"synced": True, "sync": sync, "accounting": self.store.snapshot(client_id), "tally": tally_status, "agent": self._agent_info()}
|
return {"synced": True, "sync": sync, "accounting": self.store.snapshot(client_id), "tally": tally_status, "agent": self._agent_info()}
|
||||||
|
|
||||||
def _historical_evidence(self, payload: dict[str, Any]) -> dict[str, Any]:
|
def _historical_evidence(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Refresh Tally transactions read-only, then aggregate historical evidence.
|
||||||
|
|
||||||
|
Earlier builds only aggregated transactions already present in the local
|
||||||
|
.act database. That made Purchase/Sales learning appear to "not sync"
|
||||||
|
unless the user had separately run transaction sync first.
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
client_id = int(payload.get("client_id"))
|
client_id = int(payload.get("client_id"))
|
||||||
return self.store.historical_learning_evidence(
|
requested_guid = str(payload.get("tally_guid") or "").strip()
|
||||||
client_id,
|
date_from = str(payload.get("date_from") or "").strip()
|
||||||
tally_guid=str(payload.get("tally_guid") or ""),
|
date_to = str(payload.get("date_to") or "").strip()
|
||||||
date_from=str(payload.get("date_from") or ""),
|
voucher_scope = str(payload.get("voucher_scope") or "purchase").strip().lower()
|
||||||
date_to=str(payload.get("date_to") or ""),
|
requested_by_user_id = payload.get("requested_by_user_id")
|
||||||
voucher_scope=str(payload.get("voucher_scope") or "purchase"),
|
|
||||||
|
if voucher_scope not in {"purchase", "sales", "all", "any"}:
|
||||||
|
raise ValueError("Historical evidence voucher scope must be purchase, sales or all.")
|
||||||
|
if not requested_guid:
|
||||||
|
raise ValueError("Select a currently open mapped Tally company.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
start = date.fromisoformat(date_from)
|
||||||
|
end = date.fromisoformat(date_to)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError("Select valid From Date and To Date for historical learning.") from exc
|
||||||
|
|
||||||
|
if start > end:
|
||||||
|
raise ValueError("Historical learning From Date cannot be after To Date.")
|
||||||
|
if (end - start).days > 1826:
|
||||||
|
raise ValueError("Historical learning is limited to five years per collection run.")
|
||||||
|
|
||||||
|
if not self.store.exists(client_id):
|
||||||
|
raise ValueError(
|
||||||
|
"Accounting storage is not initialized for this client. "
|
||||||
|
"Open Tally Accounting for the client and initialize/map the company first."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mapping = self.store.get_active_mapping_by_guid(client_id, requested_guid)
|
||||||
|
tally_status = self.tally.status()
|
||||||
|
if not tally_status.get("connected"):
|
||||||
|
raise ValueError(str(tally_status.get("error") or "TallyPrime is not connected."))
|
||||||
|
|
||||||
|
company = next(
|
||||||
|
(
|
||||||
|
row
|
||||||
|
for row in (tally_status.get("companies") or [])
|
||||||
|
if str(row.get("guid") or "").strip() == requested_guid
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not company:
|
||||||
|
raise ValueError(
|
||||||
|
"The selected mapped Tally company is not currently open in TallyPrime. "
|
||||||
|
"Open it in Tally and retry."
|
||||||
|
)
|
||||||
|
|
||||||
|
company_name = str(
|
||||||
|
company.get("name")
|
||||||
|
or payload.get("company_name")
|
||||||
|
or mapping.get("company_name")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
# Read-only live refresh. No voucher is created or altered.
|
||||||
|
transactions = self.tally.fetch_accounting_transactions(
|
||||||
|
company_name,
|
||||||
|
date_from,
|
||||||
|
date_to,
|
||||||
|
)
|
||||||
|
self.store.record_tally_status(client_id, tally_status)
|
||||||
|
sync = self.store.replace_transaction_snapshot(
|
||||||
|
client_id,
|
||||||
|
mapping={**mapping, "company_name": company_name},
|
||||||
|
transactions=transactions,
|
||||||
|
requested_by_user_id=(
|
||||||
|
int(requested_by_user_id)
|
||||||
|
if requested_by_user_id not in (None, "")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
evidence = self.store.historical_learning_evidence(
|
||||||
|
client_id,
|
||||||
|
tally_guid=requested_guid,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
voucher_scope=voucher_scope,
|
||||||
|
)
|
||||||
|
evidence["transaction_sync"] = sync
|
||||||
|
evidence["tally_company_name"] = company_name
|
||||||
|
evidence["refreshed_from_tally"] = True
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
"Historical %s evidence refreshed from Tally client_id=%s company=%s "
|
||||||
|
"period=%s..%s vouchers=%s evidence_rows=%s",
|
||||||
|
voucher_scope,
|
||||||
|
client_id,
|
||||||
|
company_name,
|
||||||
|
date_from,
|
||||||
|
date_to,
|
||||||
|
(sync.get("counts") or {}).get("vouchers", sync.get("rows_processed")),
|
||||||
|
evidence.get("evidence_count"),
|
||||||
|
)
|
||||||
|
return evidence
|
||||||
|
|
||||||
|
|
||||||
def _resolve_open_company(self, payload: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
def _resolve_open_company(self, payload: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
||||||
requested_guid = str(payload.get("tally_guid") or "").strip()
|
requested_guid = str(payload.get("tally_guid") or "").strip()
|
||||||
|
|||||||
Reference in New Issue
Block a user