Add Phase 5 historical Tally learning foundation

This commit is contained in:
A R R R Associates
2026-08-22 13:01:17 +05:30
parent 45fedd0d96
commit a9a5effcd0
15 changed files with 546 additions and 6 deletions
+2
View File
@@ -342,6 +342,8 @@ ROLE_PERMISSION_MAP = {
"accounting.tally.writeback",
"accounting.taxonomy.view",
"accounting.taxonomy.manage",
"accounting.learning.view",
"accounting.learning.manage",
"accounting.act.initialize",
"clients.view.own_only",
"employees.dashboard.view",
@@ -0,0 +1,74 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db.common import CommonBase
class AccountingLedgerNatureMapping(CommonBase):
__tablename__ = "accounting_ledger_nature_mappings"
__table_args__ = (
UniqueConstraint("tenant_id", "client_id", "tally_guid", "ledger_name", name="uq_accounting_ledger_nature_mapping"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
company_name: Mapped[str] = mapped_column(String(240), nullable=False, default="")
ledger_name: Mapped[str] = mapped_column(String(240), nullable=False, index=True)
parent_group: Mapped[str] = mapped_column(String(240), nullable=False, default="", index=True)
nature_id: Mapped[int] = mapped_column(ForeignKey("accounting_natures.id", ondelete="RESTRICT"), nullable=False, index=True)
source: Mapped[str] = mapped_column(String(40), nullable=False, default="manual", index=True)
confidence_percent: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
confirmed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
confirmed_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
class AccountingHistoricalLedgerEvidence(CommonBase):
__tablename__ = "accounting_historical_ledger_evidence"
__table_args__ = (
UniqueConstraint("tenant_id", "client_id", "tally_guid", "party_ledger_name", "counter_ledger_name", "voucher_type_name", name="uq_accounting_historical_ledger_evidence"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
company_name: Mapped[str] = mapped_column(String(240), nullable=False, default="")
party_ledger_name: Mapped[str] = mapped_column(String(240), nullable=False, index=True)
party_parent_group: Mapped[str] = mapped_column(String(240), nullable=False, default="")
counter_ledger_name: Mapped[str] = mapped_column(String(240), nullable=False, index=True)
counter_parent_group: Mapped[str] = mapped_column(String(240), nullable=False, default="", index=True)
voucher_type_name: Mapped[str] = mapped_column(String(160), nullable=False, default="Purchase", index=True)
voucher_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
absolute_amount_total: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
first_voucher_date: Mapped[str | None] = mapped_column(String(20), nullable=True)
last_voucher_date: Mapped[str | None] = mapped_column(String(20), nullable=True)
sample_narration: Mapped[str | None] = mapped_column(Text, nullable=True)
refreshed_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False, index=True)
class AccountingHistoricalLearningRun(CommonBase):
__tablename__ = "accounting_historical_learning_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
workstation_agent_id: Mapped[int] = mapped_column(ForeignKey("erp_workstation_agents.id", ondelete="SET NULL"), nullable=True, index=True)
agent_job_id: Mapped[int] = mapped_column(ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True, unique=True, index=True)
tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
company_name: Mapped[str] = mapped_column(String(240), nullable=False, default="")
date_from: Mapped[str | None] = mapped_column(String(20), nullable=True)
date_to: Mapped[str | None] = mapped_column(String(20), nullable=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="queued", index=True)
evidence_rows: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False, index=True)
completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,168 @@
from __future__ import annotations
import json
from collections import defaultdict
from datetime import datetime, timezone
from sqlalchemy import delete, select
from app.modules.accounting.historical_learning_models import (
AccountingHistoricalLedgerEvidence, AccountingHistoricalLearningRun, AccountingLedgerNatureMapping,
)
from app.modules.accounting.taxonomy_models import AccountingNature
from app.modules.documents.models import ERPAgentJob
NOISE_GROUPS = {
"bank accounts", "bank od a/c", "cash-in-hand", "cash in hand", "sundry creditors", "sundry debtors",
"duties & taxes", "duties and taxes", "loans (liability)", "secured loans", "unsecured loans",
}
def _utcnow():
return datetime.now(timezone.utc)
def _json(value):
try:
return json.loads(value or "{}")
except Exception:
return {}
def active_natures(db, tenant_id: int):
return list(db.execute(select(AccountingNature).where(
AccountingNature.tenant_id == tenant_id, AccountingNature.is_active.is_(True), AccountingNature.is_posting_nature.is_(True)
).order_by(AccountingNature.sort_order, AccountingNature.name)).scalars().all())
def ledger_mappings(db, tenant_id: int, client_id: int, tally_guid: str = ""):
stmt = select(AccountingLedgerNatureMapping).where(
AccountingLedgerNatureMapping.tenant_id == tenant_id, AccountingLedgerNatureMapping.client_id == client_id
)
if tally_guid:
stmt = stmt.where(AccountingLedgerNatureMapping.tally_guid == tally_guid)
return list(db.execute(stmt.order_by(AccountingLedgerNatureMapping.ledger_name)).scalars().all())
def save_ledger_mapping(db, *, tenant_id: int, client_id: int, tally_guid: str, company_name: str, ledger_name: str, parent_group: str, nature_id: int, user_id: int):
nature = db.execute(select(AccountingNature).where(
AccountingNature.id == nature_id, AccountingNature.tenant_id == tenant_id, AccountingNature.is_active.is_(True), AccountingNature.is_posting_nature.is_(True)
)).scalar_one_or_none()
if not nature:
raise ValueError("Select an active posting accounting nature.")
row = db.execute(select(AccountingLedgerNatureMapping).where(
AccountingLedgerNatureMapping.tenant_id == tenant_id, AccountingLedgerNatureMapping.client_id == client_id,
AccountingLedgerNatureMapping.tally_guid == tally_guid, AccountingLedgerNatureMapping.ledger_name == ledger_name
)).scalar_one_or_none()
if not row:
row = AccountingLedgerNatureMapping(tenant_id=tenant_id, client_id=client_id, tally_guid=tally_guid, ledger_name=ledger_name, nature_id=nature.id)
row.company_name = company_name or row.company_name or ""
row.parent_group = parent_group or row.parent_group or ""
row.nature_id = nature.id
row.source = "manual"
row.confidence_percent = 100
row.confirmed_by_user_id = user_id
row.confirmed_at_utc = _utcnow()
row.updated_at_utc = _utcnow()
db.add(row); db.commit(); db.refresh(row)
return row
def remove_ledger_mapping(db, *, tenant_id: int, client_id: int, mapping_id: int):
row = db.execute(select(AccountingLedgerNatureMapping).where(
AccountingLedgerNatureMapping.id == mapping_id, AccountingLedgerNatureMapping.tenant_id == tenant_id, AccountingLedgerNatureMapping.client_id == client_id
)).scalar_one_or_none()
if row:
db.delete(row); db.commit()
return row
def ingest_completed_run(db, run: AccountingHistoricalLearningRun):
if run.status == "completed" or not run.agent_job_id:
return run
job = db.get(ERPAgentJob, run.agent_job_id)
if not job:
run.status = "failed"; run.error_message = "Agent job no longer exists."; run.completed_at_utc = _utcnow(); db.commit(); return run
if job.status in {"queued", "claimed"}:
run.status = job.status; db.commit(); return run
if job.status != "succeeded":
run.status = "failed"; run.error_message = job.last_error or "Historical evidence collection failed."; run.completed_at_utc = _utcnow(); db.commit(); return run
result = _json(job.result_json)
rows = result.get("evidence") or []
company_name = str(result.get("company_name") or run.company_name or "")
now = _utcnow()
# Snapshot semantics for this company: remove old aggregated rows then replace with current evidence.
db.execute(delete(AccountingHistoricalLedgerEvidence).where(
AccountingHistoricalLedgerEvidence.tenant_id == run.tenant_id, AccountingHistoricalLedgerEvidence.client_id == run.client_id,
AccountingHistoricalLedgerEvidence.tally_guid == run.tally_guid
))
inserted = 0
for item in rows:
party = str(item.get("party_ledger_name") or "").strip()
counter = str(item.get("counter_ledger_name") or "").strip()
if not party or not counter or party.casefold() == counter.casefold():
continue
db.add(AccountingHistoricalLedgerEvidence(
tenant_id=run.tenant_id, client_id=run.client_id, tally_guid=run.tally_guid, company_name=company_name,
party_ledger_name=party, party_parent_group=str(item.get("party_parent_group") or ""),
counter_ledger_name=counter, counter_parent_group=str(item.get("counter_parent_group") or ""),
voucher_type_name=str(item.get("voucher_type_name") or "Purchase"), voucher_count=int(item.get("voucher_count") or 0),
absolute_amount_total=float(item.get("absolute_amount_total") or 0), first_voucher_date=str(item.get("first_voucher_date") or "") or None,
last_voucher_date=str(item.get("last_voucher_date") or "") or None, sample_narration=str(item.get("sample_narration") or "")[:2000] or None, refreshed_at_utc=now
)); inserted += 1
run.company_name = company_name
run.status = "completed"
run.evidence_rows = inserted
run.error_message = None
run.completed_at_utc = now
db.commit(); db.refresh(run)
return run
def evidence_rows(db, tenant_id: int, client_id: int):
return list(db.execute(select(AccountingHistoricalLedgerEvidence).where(
AccountingHistoricalLedgerEvidence.tenant_id == tenant_id, AccountingHistoricalLedgerEvidence.client_id == client_id
).order_by(AccountingHistoricalLedgerEvidence.voucher_count.desc(), AccountingHistoricalLedgerEvidence.party_ledger_name, AccountingHistoricalLedgerEvidence.counter_ledger_name)).scalars().all())
def classifiable_ledgers(db, tenant_id: int, client_id: int):
rows = evidence_rows(db, tenant_id, client_id)
seen = {}
for r in rows:
group = (r.counter_parent_group or "").strip().casefold()
if group in NOISE_GROUPS:
continue
key = (r.tally_guid, r.counter_ledger_name.casefold())
item = seen.setdefault(key, {"tally_guid": r.tally_guid, "company_name": r.company_name, "ledger_name": r.counter_ledger_name, "parent_group": r.counter_parent_group, "voucher_count": 0, "amount": 0.0})
item["voucher_count"] += int(r.voucher_count or 0); item["amount"] += float(r.absolute_amount_total or 0)
mappings = {(m.tally_guid, m.ledger_name.casefold()): m for m in ledger_mappings(db, tenant_id, client_id)}
for key, item in seen.items(): item["mapping"] = mappings.get(key)
return sorted(seen.values(), key=lambda x: (-x["voucher_count"], x["ledger_name"].casefold()))
def party_suggestions(db, tenant_id: int, client_id: int):
rows = evidence_rows(db, tenant_id, client_id)
mappings = {(m.tally_guid, m.ledger_name.casefold()): m for m in ledger_mappings(db, tenant_id, client_id)}
natures = {n.id: n for n in db.execute(select(AccountingNature).where(AccountingNature.tenant_id == tenant_id)).scalars().all()}
by_party = defaultdict(lambda: defaultdict(lambda: {"count": 0, "amount": 0.0, "ledgers": set()}))
party_meta = {}
for r in rows:
m = mappings.get((r.tally_guid, r.counter_ledger_name.casefold()))
if not m:
continue
key=(r.tally_guid, r.party_ledger_name)
party_meta[key]=(r.company_name, r.party_parent_group)
bucket=by_party[key][m.nature_id]
bucket["count"] += int(r.voucher_count or 0); bucket["amount"] += float(r.absolute_amount_total or 0); bucket["ledgers"].add(r.counter_ledger_name)
out=[]
for key, nature_buckets in by_party.items():
total=sum(v["count"] for v in nature_buckets.values())
if total <= 0: continue
nature_id, best=max(nature_buckets.items(), key=lambda kv:(kv[1]["count"], kv[1]["amount"]))
nature=natures.get(nature_id)
if not nature: continue
confidence=round(best["count"]*100/total)
company, parent=party_meta[key]
out.append({"tally_guid": key[0], "company_name": company, "party_ledger_name": key[1], "party_parent_group": parent, "nature": nature, "voucher_count": best["count"], "mapped_voucher_count": total, "confidence": confidence, "amount": best["amount"], "ledgers": sorted(best["ledgers"])})
return sorted(out, key=lambda x:(-x["confidence"], -x["voucher_count"], x["party_ledger_name"].casefold()))
@@ -0,0 +1,125 @@
from __future__ import annotations
import json
from datetime import date, datetime, timezone
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 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.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.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
from app.modules.documents.models import ERPAgentJob, ERPWorkstationAgent
router = APIRouter(prefix="/tools/accounting/historical-learning", tags=["accounting-historical-learning-ui"])
def _denied():
from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _fy_dates():
today=date.today(); y=today.year if today.month>=4 else today.year-1
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())
for run in runs:
if run.status not in {"completed","failed"}: ingest_completed_run(db, run)
return runs
@router.get("")
def historical_learning(request: Request, client_id: int | None=None, collected: int=0, mapped: int=0, error: str=""):
db=CommonSessionLocal()
try:
user,response=_require_partner(request,db,"accounting.learning.view")
if response: return response
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)
if selected:
runs=_sync_runs(db,scope.tenant_id,selected.id)
evid=evidence_rows(db,scope.tenant_id,selected.id)
ledgers=classifiable_ledgers(db,scope.tenant_id,selected.id)
suggestions=party_suggestions(db,scope.tenant_id,selected.id)
mappings=ledger_mappings(db,scope.tenant_id,selected.id)
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},
"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,
})
finally: db.close()
@router.post("/collect")
def collect(request: Request, client_id: int=Form(...), workstation_id: int=Form(...), tally_guid: str=Form(...), company_name: str=Form(""), date_from: str=Form(""), date_to: str=Form(""), csrf_token: str=Form(...)):
validate_csrf(request,csrf_token); db=CommonSessionLocal()
try:
user,response=_require_partner(request,db,"accounting.learning.manage")
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"})
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)
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:
db.rollback(); return RedirectResponse(url=f"/tools/accounting/historical-learning?client_id={client_id}&error={str(exc)[:120]}",status_code=303)
finally: db.close()
@router.post("/map-ledger")
def map_ledger(request: Request, client_id: int=Form(...), tally_guid: str=Form(...), company_name: str=Form(""), ledger_name: str=Form(...), parent_group: str=Form(""), nature_id: int=Form(...), csrf_token: str=Form(...)):
validate_csrf(request,csrf_token); db=CommonSessionLocal()
try:
user,response=_require_partner(request,db,"accounting.learning.manage")
if response:return response
client,_,scope=_find_visible_client(db,request,user,client_id)
if not client:return _denied()
save_ledger_mapping(db,tenant_id=scope.tenant_id,client_id=client.id,tally_guid=tally_guid,company_name=company_name,ledger_name=ledger_name,parent_group=parent_group,nature_id=nature_id,user_id=user.id)
return RedirectResponse(url=f"/tools/accounting/historical-learning?client_id={client_id}&mapped=1",status_code=303)
finally: db.close()
@router.post("/unmap-ledger")
def unmap_ledger(request: Request, client_id: int=Form(...), mapping_id: int=Form(...), csrf_token: str=Form(...)):
validate_csrf(request,csrf_token); db=CommonSessionLocal()
try:
user,response=_require_partner(request,db,"accounting.learning.manage")
if response:return response
client,_,scope=_find_visible_client(db,request,user,client_id)
if not client:return _denied()
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()
@@ -0,0 +1,59 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<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 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>
{% 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 mapped %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Ledger classification mapping saved.</div>{% endif %}
{% if error %}<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{{ error }}</div>{% endif %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<form method="get" class="flex flex-wrap items-end gap-3">
<label class="min-w-[300px] flex-1 text-sm font-medium text-slate-700">Client<select name="client_id" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="">Select client</option>{% for c in clients %}<option value="{{ c.id }}" {% if selected_client and c.id==selected_client.id %}selected{% endif %}>{{ c.client_name }}</option>{% endfor %}</select></label>
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Open</button>
</form>
</section>
{% if selected_client %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<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>
<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 }}">
<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>
<input type="hidden" name="workstation_id" id="workstationId"><input type="hidden" name="tally_guid" id="tallyGuid"><input type="hidden" name="company_name" id="companyName">
<label class="text-sm font-medium text-slate-700">From<input type="date" name="date_from" value="{{ date_from }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
<label class="text-sm font-medium text-slate-700">To<input type="date" name="date_to" value="{{ date_to }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
<div class="flex items-end"><button class="w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Collect Evidence</button></div>
</form>
</section>
<section class="rounded-2xl bg-white p-5 shadow-soft">
<h2 class="text-lg font-semibold text-slate-900">Recent Learning Runs</h2>
<div class="mt-3 overflow-x-auto"><table class="min-w-full text-sm"><thead class="text-left text-xs uppercase text-slate-500"><tr><th class="px-2 py-2">Created</th><th class="px-2 py-2">Company</th><th class="px-2 py-2">Status</th><th class="px-2 py-2">Rows</th><th class="px-2 py-2">Error</th></tr></thead><tbody>{% for r in runs %}<tr class="border-t"><td class="px-2 py-2">{{ r.created_at_utc }}</td><td class="px-2 py-2">{{ r.company_name or r.tally_guid }}</td><td class="px-2 py-2 font-medium">{{ r.status }}</td><td class="px-2 py-2">{{ r.evidence_rows }}</td><td class="px-2 py-2 text-red-700">{{ r.error_message or '' }}</td></tr>{% else %}<tr><td colspan="5" class="px-2 py-4 text-slate-500">No historical learning run yet.</td></tr>{% endfor %}</tbody></table></div>
</section>
<section class="rounded-2xl bg-white p-5 shadow-soft">
<h2 class="text-lg font-semibold text-slate-900">Classify Historical Tally Ledgers</h2><p class="mt-1 text-sm text-slate-500">Map the client's actual Tally ledger names to the canonical Phase 4 accounting taxonomy. This mapping is client and Tally-company specific.</p>
<div class="mt-3 overflow-x-auto"><table class="min-w-full text-sm"><thead class="text-left text-xs uppercase text-slate-500"><tr><th class="px-2 py-2">Ledger</th><th class="px-2 py-2">Parent</th><th class="px-2 py-2">Historical vouchers</th><th class="px-2 py-2">Amount evidence</th><th class="px-2 py-2">Accounting nature</th></tr></thead><tbody>
{% for row in classifiable_ledgers %}<tr class="border-t align-top"><td class="px-2 py-3 font-medium text-slate-900">{{ row.ledger_name }}</td><td class="px-2 py-3 text-slate-600">{{ row.parent_group }}</td><td class="px-2 py-3">{{ row.voucher_count }}</td><td class="px-2 py-3">{{ '%.2f'|format(row.amount) }}</td><td class="px-2 py-3"><form method="post" action="/tools/accounting/historical-learning/map-ledger" class="flex min-w-[360px] gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="tally_guid" value="{{ row.tally_guid }}"><input type="hidden" name="company_name" value="{{ row.company_name }}"><input type="hidden" name="ledger_name" value="{{ row.ledger_name }}"><input type="hidden" name="parent_group" value="{{ row.parent_group }}"><select name="nature_id" class="min-w-[240px] rounded-lg border border-slate-300 px-2 py-1" required><option value="">Select nature</option>{% for n in natures %}<option value="{{ n.id }}" {% if row.mapping and row.mapping.nature_id==n.id %}selected{% endif %}>{{ n.name }}</option>{% endfor %}</select><button class="rounded-lg bg-slate-900 px-3 py-1 text-xs font-semibold text-white">Save</button></form></td></tr>{% else %}<tr><td colspan="5" class="px-2 py-4 text-slate-500">Collect historical evidence first.</td></tr>{% endfor %}
</tbody></table></div>
</section>
<section class="rounded-2xl bg-white p-5 shadow-soft">
<h2 class="text-lg font-semibold text-slate-900">Supplier / Party Historical Suggestions</h2><p class="mt-1 text-sm text-slate-500">Confidence is the share of mapped historical purchase vouchers for that party that resolve to the leading accounting nature.</p>
<div class="mt-3 overflow-x-auto"><table class="min-w-full text-sm"><thead class="text-left text-xs uppercase text-slate-500"><tr><th class="px-2 py-2">Party</th><th class="px-2 py-2">Suggested nature</th><th class="px-2 py-2">Confidence</th><th class="px-2 py-2">Evidence</th><th class="px-2 py-2">Ledgers</th></tr></thead><tbody>{% for s in suggestions %}<tr class="border-t"><td class="px-2 py-3 font-medium">{{ s.party_ledger_name }}</td><td class="px-2 py-3">{{ s.nature.name }}</td><td class="px-2 py-3"><span class="rounded-full px-2 py-1 text-xs font-semibold {% if s.confidence>=90 %}bg-emerald-100 text-emerald-800{% elif s.confidence>=70 %}bg-amber-100 text-amber-800{% else %}bg-slate-100 text-slate-700{% endif %}">{{ s.confidence }}%</span></td><td class="px-2 py-3">{{ s.voucher_count }} / {{ s.mapped_voucher_count }} mapped vouchers</td><td class="px-2 py-3 text-slate-600">{{ s.ledgers|join(', ') }}</td></tr>{% else %}<tr><td colspan="5" class="px-2 py-4 text-slate-500">Map historical Tally ledgers to accounting natures to generate party suggestions.</td></tr>{% endfor %}</tbody></table></div>
</section>
<section class="rounded-2xl bg-white p-5 shadow-soft">
<h2 class="text-lg font-semibold text-slate-900">Raw Historical Evidence</h2>
<div class="mt-3 overflow-x-auto"><table class="min-w-full text-xs"><thead class="text-left uppercase text-slate-500"><tr><th class="px-2 py-2">Party</th><th class="px-2 py-2">Counter ledger</th><th class="px-2 py-2">Group</th><th class="px-2 py-2">Vouchers</th><th class="px-2 py-2">First</th><th class="px-2 py-2">Last</th></tr></thead><tbody>{% for e in evidence[:300] %}<tr class="border-t"><td class="px-2 py-2">{{ e.party_ledger_name }}</td><td class="px-2 py-2">{{ e.counter_ledger_name }}</td><td class="px-2 py-2">{{ e.counter_parent_group }}</td><td class="px-2 py-2">{{ e.voucher_count }}</td><td class="px-2 py-2">{{ e.first_voucher_date or '' }}</td><td class="px-2 py-2">{{ e.last_voucher_date or '' }}</td></tr>{% else %}<tr><td colspan="6" class="px-2 py-4 text-slate-500">No evidence collected.</td></tr>{% endfor %}</tbody></table></div>
</section>
{% endif %}
</div>
<script>
const s=document.getElementById('wsCompany'); if(s){s.addEventListener('change',()=>{const p=s.value.split('|');document.getElementById('workstationId').value=p[0]||'';document.getElementById('tallyGuid').value=p[1]||'';document.getElementById('companyName').value=p.slice(2).join('|')||'';});}
</script>
{% endblock %}
@@ -14,6 +14,7 @@
</div>
<div class="flex flex-wrap 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 hover:bg-slate-50">Accounting Taxonomy</a>
{% if selected_client %}<a href="/tools/accounting/historical-learning?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Historical Learning</a>{% endif %}
{% if selected_client %}<a href="/tools/tally/depreciation?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Depreciation (IT)</a>{% endif %}
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% else %}?refresh=1{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Refresh Tally Companies</a>
</div>
@@ -156,6 +156,8 @@ PERMISSIONS = {
"accounting.tally.writeback": "Approve and Post Accounting Entries to Tally",
"accounting.taxonomy.view": "View Accounting Classification Taxonomy",
"accounting.taxonomy.manage": "Activate or Deactivate Accounting Classification Natures",
"accounting.learning.view": "View Historical Tally Learning",
"accounting.learning.manage": "Collect Evidence and Map Historical Tally Ledgers",
"accounting.act.initialize": "Initialize Client Accounting ACT Storage",
"notice_cases.view": "View Notice and Case Management",
+2 -2
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path
import zipfile
ERP_LOCAL_AGENT_VERSION = "1.11.0"
ERP_LOCAL_AGENT_VERSION = "1.12.0"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
@@ -53,7 +53,7 @@ def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme:
text = (
f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n"
"Existing storage, WebSocket tunnel, dashboard, Tally mapping and client .act functionality are preserved.\n"
"Phase 3 master synchronization is preserved. Phase 4 adds read-only date-scoped Tally voucher/transaction synchronization.\n"
"Existing master/transaction sync and depreciation are preserved. Phase 5 adds neutral historical-ledger evidence read capability for server-side learning.\n"
"Dashboard: http://127.0.0.1:8788\n"
)
_write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", text.encode("utf-8"))
@@ -1,2 +1 @@
__version__ = "1.11.0"
AGENT_NAME = "ERP Local Agent"
__version__ = "1.12.0"
@@ -1077,6 +1077,54 @@ class LocalAccountingStore:
).fetchall()
return [dict(row) for row in rows]
def historical_learning_evidence(self, client_id: int, *, tally_guid: str, date_from: str = "", date_to: str = "", voucher_scope: str = "purchase") -> dict:
"""Return neutral aggregated historical ledger facts from the local .act store.
No accounting classification is performed locally. The ERP server maps
these facts to its canonical taxonomy.
"""
if not self.exists(client_id):
raise ValueError("Accounting storage is not initialized for this client.")
guid = str(tally_guid or "").strip()
if not guid:
raise ValueError("Tally company GUID is required.")
mapping = self.get_active_mapping_by_guid(client_id, guid)
params: list = [guid]
where = ["v.tally_guid=?", "TRIM(COALESCE(v.party_ledger_name,''))<>''", "LOWER(TRIM(COALESCE(v.is_cancelled,''))) NOT IN ('yes','true','1')", "LOWER(TRIM(e.ledger_name))<>LOWER(TRIM(v.party_ledger_name))"]
if str(voucher_scope or "purchase").strip().lower() == "purchase":
where.append("LOWER(v.voucher_type_name) LIKE '%purchase%'")
if date_from:
where.append("v.voucher_date>=?"); params.append(str(date_from))
if date_to:
where.append("v.voucher_date<=?"); params.append(str(date_to))
sql = f"""
SELECT v.party_ledger_name AS party_ledger_name,
COALESCE(pl.parent,'') AS party_parent_group,
e.ledger_name AS counter_ledger_name,
COALESCE(cl.parent,'') AS counter_parent_group,
v.voucher_type_name AS voucher_type_name,
COUNT(DISTINCT v.id) AS voucher_count,
COALESCE(SUM(ABS(e.amount)),0) AS absolute_amount_total,
MIN(NULLIF(v.voucher_date,'')) AS first_voucher_date,
MAX(NULLIF(v.voucher_date,'')) AS last_voucher_date,
MIN(NULLIF(v.narration,'')) AS sample_narration
FROM tally_vouchers v
JOIN tally_voucher_ledger_entries e ON e.voucher_id=v.id
LEFT JOIN tally_ledgers pl ON pl.tally_guid=v.tally_guid AND LOWER(TRIM(pl.name))=LOWER(TRIM(v.party_ledger_name))
LEFT JOIN tally_ledgers cl ON cl.tally_guid=v.tally_guid AND LOWER(TRIM(cl.name))=LOWER(TRIM(e.ledger_name))
WHERE {' AND '.join(where)}
GROUP BY v.party_ledger_name, COALESCE(pl.parent,''), e.ledger_name, COALESCE(cl.parent,''), v.voucher_type_name
ORDER BY voucher_count DESC, v.party_ledger_name COLLATE NOCASE, e.ledger_name COLLATE NOCASE
LIMIT 5000
"""
with self.connect(client_id) as db:
rows = [dict(r) for r in db.execute(sql, tuple(params)).fetchall()]
return {
"client_id": int(client_id), "tally_guid": guid, "company_name": str(mapping.get("company_name") or ""),
"date_from": str(date_from or ""), "date_to": str(date_to or ""), "voucher_scope": str(voucher_scope or "purchase"),
"evidence": rows, "evidence_count": len(rows),
}
def snapshot(self, client_id: int) -> dict:
path = self.db_path(client_id)
if not path.is_file():
@@ -36,6 +36,8 @@ class AgentCommandProcessor:
result = self._sync_masters(payload)
elif action == "accounting_sync_transactions":
result = self._sync_transactions(payload)
elif action == "accounting_historical_evidence":
result = self._historical_evidence(payload)
elif action == "accounting_depreciation_preview":
result = self._depreciation_preview(payload)
elif action == "accounting_calculate_it_depreciation":
@@ -67,6 +69,7 @@ class AgentCommandProcessor:
"tally_capability": True, "accounting_act_capability": True,
"tally_mapping_capability": True, "tally_master_sync_capability": True,
"tally_transaction_sync_capability": True,
"historical_learning_read_capability": True,
"it_depreciation_capability": True,
"tally_writeback_capability": True,
}
@@ -201,6 +204,16 @@ class AgentCommandProcessor:
self.logger.info("Tally transaction sync completed client_id=%s company=%s period=%s..%s rows=%s", client_id, company_name, date_from, date_to, sync.get("rows_processed"))
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]:
client_id = int(payload.get("client_id"))
return self.store.historical_learning_evidence(
client_id,
tally_guid=str(payload.get("tally_guid") or ""),
date_from=str(payload.get("date_from") or ""),
date_to=str(payload.get("date_to") or ""),
voucher_scope=str(payload.get("voucher_scope") or "purchase"),
)
def _depreciation_preview(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id"))
tally_guid = str(payload.get("tally_guid") or "").strip()
@@ -49,7 +49,7 @@ class StorageAgent:
"free_bytes": free,
"agent_version": __version__,
"agent_name": "ERP Local Agent",
"capabilities": ["storage", "tally", "accounting_act", "tally_master_sync", "tally_transaction_sync", "local_dashboard", "manual_updates"],
"capabilities": ["storage", "tally", "accounting_act", "tally_master_sync", "tally_transaction_sync", "local_dashboard", "manual_updates", "historical_learning_read"],
"agent_time_utc": datetime.now(timezone.utc).isoformat(),
}
self.client.heartbeat(payload)
@@ -190,7 +190,7 @@ class StorageAgentTunnel:
def _capabilities(self) -> list[str]:
return [
"storage", "local_dashboard", "manual_updates", "accounting_act", "agent.jobs.v2",
"storage", "local_dashboard", "manual_updates", "historical_learning_read", "accounting_act", "agent.jobs.v2",
"tally.status", "tally.company_identity", "tally.read_groups",
"tally.read_ledgers", "tally.read_stock_items", "tally.read_vouchers",
"tally.read_trial_balance", "tally.master_sync", "tally.transaction_sync",
+2
View File
@@ -38,6 +38,7 @@ from app.modules.peer_review_export.ui import router as peer_review_export_route
from app.modules.bank_statement_analyzer.ui import router as bank_statement_analyzer_router
from app.modules.accounting.ui import router as accounting_ui_router
from app.modules.accounting.taxonomy_ui import router as accounting_taxonomy_ui_router
from app.modules.accounting.historical_learning_ui import router as accounting_historical_learning_ui_router
from app.modules.registrations.ui import router as registrations_ui_router
from app.modules.credential_vault.ui import router as credential_vault_ui_router
from app.modules.client_identity.ui import router as client_identity_ui_router
@@ -62,6 +63,7 @@ def mount_ui(app: FastAPI) -> None:
app.include_router(bank_statement_analyzer_router)
app.include_router(accounting_ui_router)
app.include_router(accounting_taxonomy_ui_router)
app.include_router(accounting_historical_learning_ui_router)
app.include_router(work_tracker_ui_router)
app.include_router(billing_ui_router)
app.include_router(platform_billing_ui_router)