Fix live Purchase and Sales historical Tally sync

This commit is contained in:
A R R R Associates
2026-08-23 11:48:09 +05:30
parent 395f4eed1a
commit 6910b5b6b5
8 changed files with 293 additions and 68 deletions
@@ -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
+16 -29
View File
@@ -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 %}
+1 -1
View File
@@ -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()