diff --git a/alembic/versions/20260822_historical_tally_learning_phase5.py b/alembic/versions/20260822_historical_tally_learning_phase5.py new file mode 100644 index 0000000..5ee0d49 --- /dev/null +++ b/alembic/versions/20260822_historical_tally_learning_phase5.py @@ -0,0 +1,47 @@ +"""Phase 5 historical Tally learning foundation. + +Revision ID: 20260822_historical_learning_p5 +Revises: 20260822_accounting_taxonomy_p4 +""" +from alembic import op +import sqlalchemy as sa + +revision = "20260822_historical_learning_p5" +down_revision = "20260822_accounting_taxonomy_p4" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table("accounting_ledger_nature_mappings", + sa.Column("id",sa.Integer(),primary_key=True), sa.Column("tenant_id",sa.Integer(),sa.ForeignKey("tenants.id",ondelete="CASCADE"),nullable=False), + sa.Column("client_id",sa.Integer(),sa.ForeignKey("clients.id",ondelete="CASCADE"),nullable=False), sa.Column("tally_guid",sa.String(120),nullable=False), + sa.Column("company_name",sa.String(240),nullable=False,server_default=""), sa.Column("ledger_name",sa.String(240),nullable=False), sa.Column("parent_group",sa.String(240),nullable=False,server_default=""), + sa.Column("nature_id",sa.Integer(),sa.ForeignKey("accounting_natures.id",ondelete="RESTRICT"),nullable=False), sa.Column("source",sa.String(40),nullable=False,server_default="manual"), + sa.Column("confidence_percent",sa.Integer(),nullable=False,server_default="100"), sa.Column("confirmed_by_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL"),nullable=True), + sa.Column("confirmed_at_utc",sa.DateTime(timezone=True),nullable=False,server_default=sa.func.now()), sa.Column("created_at_utc",sa.DateTime(timezone=True),nullable=False,server_default=sa.func.now()), sa.Column("updated_at_utc",sa.DateTime(timezone=True),nullable=False,server_default=sa.func.now()), + sa.UniqueConstraint("tenant_id","client_id","tally_guid","ledger_name",name="uq_accounting_ledger_nature_mapping")) + for c in ["tenant_id","client_id","tally_guid","ledger_name","parent_group","nature_id","source"]: op.create_index(f"ix_accounting_ledger_nature_mappings_{c}","accounting_ledger_nature_mappings",[c]) + + op.create_table("accounting_historical_ledger_evidence", + sa.Column("id",sa.Integer(),primary_key=True), sa.Column("tenant_id",sa.Integer(),sa.ForeignKey("tenants.id",ondelete="CASCADE"),nullable=False), sa.Column("client_id",sa.Integer(),sa.ForeignKey("clients.id",ondelete="CASCADE"),nullable=False), + sa.Column("tally_guid",sa.String(120),nullable=False), sa.Column("company_name",sa.String(240),nullable=False,server_default=""), sa.Column("party_ledger_name",sa.String(240),nullable=False), sa.Column("party_parent_group",sa.String(240),nullable=False,server_default=""), + sa.Column("counter_ledger_name",sa.String(240),nullable=False), sa.Column("counter_parent_group",sa.String(240),nullable=False,server_default=""), sa.Column("voucher_type_name",sa.String(160),nullable=False,server_default="Purchase"), + sa.Column("voucher_count",sa.Integer(),nullable=False,server_default="0"), sa.Column("absolute_amount_total",sa.Float(),nullable=False,server_default="0"), sa.Column("first_voucher_date",sa.String(20),nullable=True), sa.Column("last_voucher_date",sa.String(20),nullable=True), + sa.Column("sample_narration",sa.Text(),nullable=True), sa.Column("refreshed_at_utc",sa.DateTime(timezone=True),nullable=False,server_default=sa.func.now()), + sa.UniqueConstraint("tenant_id","client_id","tally_guid","party_ledger_name","counter_ledger_name","voucher_type_name",name="uq_accounting_historical_ledger_evidence")) + for c in ["tenant_id","client_id","tally_guid","party_ledger_name","counter_ledger_name","counter_parent_group","voucher_type_name","refreshed_at_utc"]: op.create_index(f"ix_accounting_historical_ledger_evidence_{c}","accounting_historical_ledger_evidence",[c]) + + op.create_table("accounting_historical_learning_runs", + sa.Column("id",sa.Integer(),primary_key=True), sa.Column("tenant_id",sa.Integer(),sa.ForeignKey("tenants.id",ondelete="CASCADE"),nullable=False), sa.Column("client_id",sa.Integer(),sa.ForeignKey("clients.id",ondelete="CASCADE"),nullable=False), + sa.Column("workstation_agent_id",sa.Integer(),sa.ForeignKey("erp_workstation_agents.id",ondelete="SET NULL"),nullable=True), sa.Column("agent_job_id",sa.Integer(),sa.ForeignKey("erp_agent_jobs.id",ondelete="SET NULL"),nullable=True,unique=True), + sa.Column("tally_guid",sa.String(120),nullable=False), sa.Column("company_name",sa.String(240),nullable=False,server_default=""), sa.Column("date_from",sa.String(20),nullable=True), sa.Column("date_to",sa.String(20),nullable=True), + sa.Column("status",sa.String(30),nullable=False,server_default="queued"), sa.Column("evidence_rows",sa.Integer(),nullable=False,server_default="0"), sa.Column("error_message",sa.Text(),nullable=True), sa.Column("requested_by_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL"),nullable=True), + sa.Column("created_at_utc",sa.DateTime(timezone=True),nullable=False,server_default=sa.func.now()), sa.Column("completed_at_utc",sa.DateTime(timezone=True),nullable=True)) + for c in ["tenant_id","client_id","workstation_agent_id","agent_job_id","tally_guid","status","created_at_utc"]: op.create_index(f"ix_accounting_historical_learning_runs_{c}","accounting_historical_learning_runs",[c], unique=(c=="agent_job_id")) + + +def downgrade(): + op.drop_table("accounting_historical_learning_runs") + op.drop_table("accounting_historical_ledger_evidence") + op.drop_table("accounting_ledger_nature_mappings") diff --git a/app/core/startup.py b/app/core/startup.py index 03b01a9..1f8913c 100644 --- a/app/core/startup.py +++ b/app/core/startup.py @@ -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", diff --git a/app/modules/accounting/historical_learning_models.py b/app/modules/accounting/historical_learning_models.py new file mode 100644 index 0000000..278105e --- /dev/null +++ b/app/modules/accounting/historical_learning_models.py @@ -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) diff --git a/app/modules/accounting/historical_learning_service.py b/app/modules/accounting/historical_learning_service.py new file mode 100644 index 0000000..3ba2fcc --- /dev/null +++ b/app/modules/accounting/historical_learning_service.py @@ -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())) diff --git a/app/modules/accounting/historical_learning_ui.py b/app/modules/accounting/historical_learning_ui.py new file mode 100644 index 0000000..2367a57 --- /dev/null +++ b/app/modules/accounting/historical_learning_ui.py @@ -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() diff --git a/app/modules/accounting/templates/accounting/historical_learning.html b/app/modules/accounting/templates/accounting/historical_learning.html new file mode 100644 index 0000000..341e1fb --- /dev/null +++ b/app/modules/accounting/templates/accounting/historical_learning.html @@ -0,0 +1,59 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
Tools · Accounting · Phase 5
Uses synced Tally purchase history as evidence. No voucher is created or altered in this phase.
Select the workstation that holds this client's synced .act data. The agent returns neutral ledger-history facts; classification stays on the ERP server.
+ +| Created | Company | Status | Rows | Error |
|---|---|---|---|---|
| {{ r.created_at_utc }} | {{ r.company_name or r.tally_guid }} | {{ r.status }} | {{ r.evidence_rows }} | {{ r.error_message or '' }} |
| No historical learning run yet. | ||||
Map the client's actual Tally ledger names to the canonical Phase 4 accounting taxonomy. This mapping is client and Tally-company specific.
+| Ledger | Parent | Historical vouchers | Amount evidence | Accounting nature |
|---|---|---|---|---|
| {{ row.ledger_name }} | {{ row.parent_group }} | {{ row.voucher_count }} | {{ '%.2f'|format(row.amount) }} | |
| Collect historical evidence first. | ||||
Confidence is the share of mapped historical purchase vouchers for that party that resolve to the leading accounting nature.
+| Party | Suggested nature | Confidence | Evidence | Ledgers |
|---|---|---|---|---|
| {{ s.party_ledger_name }} | {{ s.nature.name }} | {{ s.confidence }}% | {{ s.voucher_count }} / {{ s.mapped_voucher_count }} mapped vouchers | {{ s.ledgers|join(', ') }} |
| Map historical Tally ledgers to accounting natures to generate party suggestions. | ||||
| Party | Counter ledger | Group | Vouchers | First | Last |
|---|---|---|---|---|---|
| {{ e.party_ledger_name }} | {{ e.counter_ledger_name }} | {{ e.counter_parent_group }} | {{ e.voucher_count }} | {{ e.first_voucher_date or '' }} | {{ e.last_voucher_date or '' }} |
| No evidence collected. | |||||