From 230fe7ffc5b45f73836e8fbe207cebc917d6a6ca Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Sat, 22 Aug 2026 22:43:44 +0530 Subject: [PATCH] Add Phase 15 customer and sales ledger intelligence --- ...0260822_sales_customer_learning_phase15.py | 95 ++++++ .../accounting/sales_learning_models.py | 118 +++++++ .../accounting/sales_learning_service.py | 319 ++++++++++++++++++ app/modules/accounting/sales_learning_ui.py | 206 +++++++++++ app/modules/accounting/sales_service.py | 32 +- .../templates/accounting/sales_income.html | 7 +- .../templates/accounting/sales_learning.html | 92 +++++ .../templates/accounting/tally.html | 1 + app/modules/documents/agent_package.py | 2 +- .../erp_local_agent/__init__.py | 2 +- .../erp_local_agent/accounting_store.py | 9 +- app/ui/app.py | 2 + 12 files changed, 879 insertions(+), 6 deletions(-) create mode 100644 alembic/versions/20260822_sales_customer_learning_phase15.py create mode 100644 app/modules/accounting/sales_learning_models.py create mode 100644 app/modules/accounting/sales_learning_service.py create mode 100644 app/modules/accounting/sales_learning_ui.py create mode 100644 app/modules/accounting/templates/accounting/sales_learning.html diff --git a/alembic/versions/20260822_sales_customer_learning_phase15.py b/alembic/versions/20260822_sales_customer_learning_phase15.py new file mode 100644 index 0000000..7cb1656 --- /dev/null +++ b/alembic/versions/20260822_sales_customer_learning_phase15.py @@ -0,0 +1,95 @@ +"""Phase 15 customer and sales ledger intelligence. + +Revision ID: 20260822_sales_customer_learning_p15 +Revises: 20260822_sales_income_foundation_p14 +""" +from alembic import op +import sqlalchemy as sa + + +revision = "20260822_sales_customer_learning_p15" +down_revision = "20260822_sales_income_foundation_p14" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "accounting_sales_historical_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("tally_guid", sa.String(120), nullable=False, server_default=""), + sa.Column("company_name", sa.String(255), nullable=False, server_default=""), + 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), + sa.Column("date_from", sa.String(20), nullable=False, server_default=""), + sa.Column("date_to", sa.String(20), nullable=False, server_default=""), + 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 name in ("tenant_id","client_id","tally_guid","workstation_agent_id","agent_job_id","status","created_at_utc"): + op.create_index(f"ix_accounting_sales_historical_runs_{name}", "accounting_sales_historical_runs", [name]) + + op.create_table( + "accounting_sales_historical_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, server_default=""), + sa.Column("company_name", sa.String(255), nullable=False, server_default=""), + sa.Column("party_ledger_name", sa.String(255), nullable=False), + sa.Column("party_parent_group", sa.String(255), nullable=False, server_default=""), + sa.Column("counter_ledger_name", sa.String(255), nullable=False), + sa.Column("counter_parent_group", sa.String(255), nullable=False, server_default=""), + sa.Column("voucher_type_name", sa.String(160), nullable=False, server_default="Sales"), + 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_sales_hist_evidence", + ), + ) + for name in ("tenant_id","client_id","tally_guid","party_ledger_name","counter_ledger_name","voucher_type_name","refreshed_at_utc"): + op.create_index(f"ix_accounting_sales_historical_evidence_{name}", "accounting_sales_historical_evidence", [name]) + + op.create_table( + "accounting_sales_customer_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("customer_name", sa.String(255), nullable=False, server_default=""), + sa.Column("normalized_customer_name", sa.String(255), nullable=False, server_default=""), + sa.Column("customer_gstin", sa.String(20), nullable=False, server_default=""), + sa.Column("identity_key", sa.String(300), nullable=False), + sa.Column("tally_guid", sa.String(120), nullable=False, server_default=""), + sa.Column("tally_customer_ledger_name", sa.String(255), nullable=False, server_default=""), + sa.Column("nature_id", sa.Integer(), sa.ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True), + sa.Column("sales_ledger_name", sa.String(255), nullable=False, server_default=""), + sa.Column("source", sa.String(30), nullable=False, server_default="review"), + sa.Column("evidence_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("confidence_percent", sa.Integer(), nullable=False, server_default="0"), + sa.Column("total_amount", sa.Float(), nullable=False, server_default="0"), + sa.Column("last_seen_date", sa.String(20), nullable=False, server_default=""), + 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=True), + 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","identity_key", name="uq_sales_customer_mapping_identity"), + ) + for name in ("tenant_id","client_id","normalized_customer_name","customer_gstin","identity_key","tally_guid","nature_id","source","created_at_utc"): + op.create_index(f"ix_accounting_sales_customer_mappings_{name}", "accounting_sales_customer_mappings", [name]) + + +def downgrade(): + op.drop_table("accounting_sales_customer_mappings") + op.drop_table("accounting_sales_historical_evidence") + op.drop_table("accounting_sales_historical_runs") diff --git a/app/modules/accounting/sales_learning_models.py b/app/modules/accounting/sales_learning_models.py new file mode 100644 index 0000000..f94b989 --- /dev/null +++ b/app/modules/accounting/sales_learning_models.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db.common import CommonBase + + +class AccountingSalesHistoricalRun(CommonBase): + __tablename__ = "accounting_sales_historical_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) + tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="", index=True) + company_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + workstation_agent_id: Mapped[int | None] = mapped_column( + ForeignKey("erp_workstation_agents.id", ondelete="SET NULL"), nullable=True, index=True + ) + agent_job_id: Mapped[int | None] = mapped_column( + ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True, index=True + ) + date_from: Mapped[str] = mapped_column(String(20), nullable=False, default="") + date_to: Mapped[str] = mapped_column(String(20), nullable=False, default="") + 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), nullable=False, default=lambda: datetime.now(timezone.utc), index=True + ) + completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class AccountingSalesHistoricalEvidence(CommonBase): + __tablename__ = "accounting_sales_historical_evidence" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "client_id", + "tally_guid", + "party_ledger_name", + "counter_ledger_name", + "voucher_type_name", + name="uq_sales_hist_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, default="", index=True) + company_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + party_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + party_parent_group: Mapped[str] = mapped_column(String(255), nullable=False, default="") + counter_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + counter_parent_group: Mapped[str] = mapped_column(String(255), nullable=False, default="") + voucher_type_name: Mapped[str] = mapped_column(String(160), nullable=False, default="Sales", 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) + 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), nullable=False, default=lambda: datetime.now(timezone.utc), index=True + ) + + +class AccountingSalesCustomerMapping(CommonBase): + __tablename__ = "accounting_sales_customer_mappings" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "client_id", + "identity_key", + name="uq_sales_customer_mapping_identity", + ), + ) + + 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) + + customer_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + normalized_customer_name: Mapped[str] = mapped_column(String(255), nullable=False, default="", index=True) + customer_gstin: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True) + identity_key: Mapped[str] = mapped_column(String(300), nullable=False, index=True) + + tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="", index=True) + tally_customer_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + nature_id: Mapped[int | None] = mapped_column( + ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True, index=True + ) + sales_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + + source: Mapped[str] = mapped_column(String(30), nullable=False, default="review", index=True) + evidence_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + confidence_percent: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + total_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0) + last_seen_date: Mapped[str] = mapped_column(String(20), nullable=False, default="") + + confirmed_by_user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + confirmed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc), index=True + ) + updated_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/app/modules/accounting/sales_learning_service.py b/app/modules/accounting/sales_learning_service.py new file mode 100644 index 0000000..375e352 --- /dev/null +++ b/app/modules/accounting/sales_learning_service.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +import json +import re +from collections import defaultdict +from datetime import datetime, timezone + +from sqlalchemy import delete, func, select + +from app.modules.accounting.sales_learning_models import ( + AccountingSalesCustomerMapping, + AccountingSalesHistoricalEvidence, + AccountingSalesHistoricalRun, +) +from app.modules.accounting.sales_models import AccountingSalesIncomeTransaction +from app.modules.accounting.taxonomy_models import AccountingNature +from app.modules.documents.models import ERPAgentJob + + +NOISE_COUNTER_GROUPS = { + "sundry debtors", "cash-in-hand", "cash in hand", "bank accounts", + "duties & taxes", "duties and taxes", "sales accounts", +} + + +def _utcnow(): + return datetime.now(timezone.utc) + + +def _s(value): + return str(value or "").strip() + + +def normalize_party(value: str) -> str: + text = re.sub(r"[^A-Z0-9]+", " ", _s(value).upper()).strip() + noise = { + "PRIVATE", "PVT", "LIMITED", "LTD", "LLP", "THE", "INDIA", + "M", "S", "MS", "MR", "MRS", + } + return " ".join(token for token in text.split() if token not in noise) + + +def identity_key(customer_name: str, customer_gstin: str) -> str: + gstin = re.sub(r"\s+", "", _s(customer_gstin).upper()) + if gstin: + return "GSTIN:" + gstin + return "NAME:" + normalize_party(customer_name) + + +def sales_mappings(db, *, tenant_id: int, client_id: int): + return list(db.execute( + select(AccountingSalesCustomerMapping).where( + AccountingSalesCustomerMapping.tenant_id == int(tenant_id), + AccountingSalesCustomerMapping.client_id == int(client_id), + ).order_by( + AccountingSalesCustomerMapping.confidence_percent.desc(), + AccountingSalesCustomerMapping.customer_name, + ) + ).scalars().all()) + + +def mapping_for_customer(db, *, tenant_id: int, client_id: int, customer_name: str, customer_gstin: str): + key = identity_key(customer_name, customer_gstin) + if key in {"NAME:", "GSTIN:"}: + return None + return db.execute( + select(AccountingSalesCustomerMapping).where( + AccountingSalesCustomerMapping.tenant_id == int(tenant_id), + AccountingSalesCustomerMapping.client_id == int(client_id), + AccountingSalesCustomerMapping.identity_key == key, + ) + ).scalar_one_or_none() + + +def save_review_mapping( + db, + *, + tenant_id: int, + client_id: int, + customer_name: str, + customer_gstin: str, + nature_id: int, + sales_ledger_name: str, + tally_guid: str = "", + tally_customer_ledger_name: str = "", + user_id: int, + amount: float = 0, + invoice_date: str = "", +): + key = identity_key(customer_name, customer_gstin) + if key in {"NAME:", "GSTIN:"}: + return None + + row = db.execute( + select(AccountingSalesCustomerMapping).where( + AccountingSalesCustomerMapping.tenant_id == int(tenant_id), + AccountingSalesCustomerMapping.client_id == int(client_id), + AccountingSalesCustomerMapping.identity_key == key, + ) + ).scalar_one_or_none() + + if not row: + row = AccountingSalesCustomerMapping( + tenant_id=int(tenant_id), + client_id=int(client_id), + identity_key=key, + customer_name=_s(customer_name), + normalized_customer_name=normalize_party(customer_name), + customer_gstin=re.sub(r"\s+", "", _s(customer_gstin).upper()), + evidence_count=0, + ) + + row.customer_name = _s(customer_name) or row.customer_name + row.normalized_customer_name = normalize_party(row.customer_name) + row.customer_gstin = re.sub(r"\s+", "", _s(customer_gstin).upper()) or row.customer_gstin + row.nature_id = int(nature_id) + row.sales_ledger_name = _s(sales_ledger_name) + row.tally_guid = _s(tally_guid) or row.tally_guid + row.tally_customer_ledger_name = _s(tally_customer_ledger_name) or row.tally_customer_ledger_name + row.source = "review" + row.evidence_count = int(row.evidence_count or 0) + 1 + row.total_amount = float(row.total_amount or 0) + abs(float(amount or 0)) + row.last_seen_date = _s(invoice_date) or row.last_seen_date + row.confidence_percent = min(100, max(90, 70 + row.evidence_count * 5)) + 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 ingest_completed_sales_run(db, run: AccountingSalesHistoricalRun): + 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 Sales evidence collection failed." + run.completed_at_utc = _utcnow() + db.commit() + return run + + try: + result = json.loads(job.result_json or "{}") + except Exception: + result = {} + + rows = result.get("evidence") or [] + now = _utcnow() + company_name = _s(result.get("company_name") or run.company_name) + + # Sales evidence is stored in its own table; Purchase history is untouched. + db.execute( + delete(AccountingSalesHistoricalEvidence).where( + AccountingSalesHistoricalEvidence.tenant_id == run.tenant_id, + AccountingSalesHistoricalEvidence.client_id == run.client_id, + AccountingSalesHistoricalEvidence.tally_guid == run.tally_guid, + ) + ) + + inserted = 0 + for item in rows: + voucher_type = _s(item.get("voucher_type_name")) + if "sales" not in voucher_type.casefold(): + continue + + party = _s(item.get("party_ledger_name")) + counter = _s(item.get("counter_ledger_name")) + if not party or not counter or party.casefold() == counter.casefold(): + continue + + db.add( + AccountingSalesHistoricalEvidence( + 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=_s(item.get("party_parent_group")), + counter_ledger_name=counter, + counter_parent_group=_s(item.get("counter_parent_group")), + voucher_type_name=voucher_type or "Sales", + voucher_count=int(item.get("voucher_count") or 0), + absolute_amount_total=float(item.get("absolute_amount_total") or 0), + first_voucher_date=_s(item.get("first_voucher_date")) or None, + last_voucher_date=_s(item.get("last_voucher_date")) or None, + sample_narration=_s(item.get("sample_narration"))[: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 historical_sales_rows(db, *, tenant_id: int, client_id: int): + return list(db.execute( + select(AccountingSalesHistoricalEvidence).where( + AccountingSalesHistoricalEvidence.tenant_id == int(tenant_id), + AccountingSalesHistoricalEvidence.client_id == int(client_id), + ).order_by( + AccountingSalesHistoricalEvidence.voucher_count.desc(), + AccountingSalesHistoricalEvidence.party_ledger_name, + AccountingSalesHistoricalEvidence.counter_ledger_name, + ) + ).scalars().all()) + + +def historical_party_rank(db, *, tenant_id: int, client_id: int, customer_name: str): + target = normalize_party(customer_name) + if not target: + return [] + + candidates = [] + for row in historical_sales_rows(db, tenant_id=tenant_id, client_id=client_id): + if normalize_party(row.party_ledger_name) != target: + continue + parent = _s(row.counter_parent_group).casefold() + # In sales vouchers the useful counter ledger is normally the sales/income ledger. + if parent in {"sundry debtors", "bank accounts", "cash-in-hand", "cash in hand"}: + continue + candidates.append(row) + + return sorted( + candidates, + key=lambda row: (-int(row.voucher_count or 0), -float(row.absolute_amount_total or 0)), + ) + + +def suggestion_for_customer(db, *, tenant_id: int, client_id: int, customer_name: str, customer_gstin: str): + mapping = mapping_for_customer( + db, + tenant_id=tenant_id, + client_id=client_id, + customer_name=customer_name, + customer_gstin=customer_gstin, + ) + if mapping and mapping.nature_id: + nature = db.get(AccountingNature, mapping.nature_id) + if nature and nature.is_active: + return { + "nature": nature, + "ledger_name": mapping.sales_ledger_name, + "customer_ledger_name": mapping.tally_customer_ledger_name, + "confidence": int(mapping.confidence_percent or 0), + "reason": ( + f"Confirmed customer mapping from {int(mapping.evidence_count or 0)} " + f"reviewed transaction(s)." + ), + "source": "confirmed_customer_mapping", + } + + history = historical_party_rank( + db, + tenant_id=tenant_id, + client_id=client_id, + customer_name=customer_name, + ) + if history: + best = history[0] + # Historical Tally alone knows the sales ledger but not canonical nature. + # Try an existing mapping that uses this exact sales ledger. + linked = db.execute( + select(AccountingSalesCustomerMapping).where( + AccountingSalesCustomerMapping.tenant_id == int(tenant_id), + AccountingSalesCustomerMapping.client_id == int(client_id), + func.lower(AccountingSalesCustomerMapping.sales_ledger_name) + == best.counter_ledger_name.casefold(), + AccountingSalesCustomerMapping.nature_id.is_not(None), + ).order_by(AccountingSalesCustomerMapping.confidence_percent.desc()).limit(1) + ).scalar_one_or_none() + + nature = db.get(AccountingNature, linked.nature_id) if linked and linked.nature_id else None + return { + "nature": nature, + "ledger_name": best.counter_ledger_name, + "customer_ledger_name": best.party_ledger_name, + "confidence": min(88, 55 + int(best.voucher_count or 0) * 3), + "reason": ( + f"Historical Tally Sales evidence: {int(best.voucher_count or 0)} voucher(s) " + f"used ledger '{best.counter_ledger_name}'." + ), + "source": "historical_tally_sales", + } + + return None + + +def learning_summary(db, *, tenant_id: int, client_id: int): + mappings = sales_mappings(db, tenant_id=tenant_id, client_id=client_id) + history = historical_sales_rows(db, tenant_id=tenant_id, client_id=client_id) + return { + "mapping_count": len(mappings), + "historical_rows": len(history), + "review_confirmed": sum(1 for row in mappings if row.source == "review"), + "high_confidence": sum(1 for row in mappings if int(row.confidence_percent or 0) >= 90), + } diff --git a/app/modules/accounting/sales_learning_ui.py b/app/modules/accounting/sales_learning_ui.py new file mode 100644 index 0000000..165a9c2 --- /dev/null +++ b/app/modules/accounting/sales_learning_ui.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +from datetime import date, timedelta +from urllib.parse import urlencode + +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.csrf 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.ledger_learning_service import available_tally_guids +from app.modules.accounting.sales_learning_models import AccountingSalesHistoricalRun +from app.modules.accounting.sales_learning_service import ( + historical_sales_rows, + ingest_completed_sales_run, + learning_summary, + sales_mappings, +) +from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.documents.agent_jobs import enqueue_agent_job +from app.modules.documents.models import ERPWorkstationAgent + +router = APIRouter( + prefix="/tools/accounting/sales-learning", + tags=["accounting-sales-learning-ui"], +) + + +def _go(client_id=0, message="", error=""): + q = {} + if client_id: + q["client_id"] = client_id + if message: + q["message"] = message[:300] + if error: + q["error"] = error[:300] + return RedirectResponse( + "/tools/accounting/sales-learning" + ("?" + urlencode(q) if q else ""), + status_code=303, + ) + + +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("") +def page( + request: Request, + client_id: int | None = None, + message: str = "", + error: str = "", +): + db = CommonSessionLocal() + try: + user, denied = _require_partner(request, db, "accounting.learning.view") + if denied: + return denied + + clients, scope = _visible_clients(db, request, user) + selected = next( + (row for row in clients if client_id and int(row.id) == int(client_id)), + None, + ) + + mappings = [] + evidence = [] + summary = {"mapping_count": 0, "historical_rows": 0, "review_confirmed": 0, "high_confidence": 0} + guids = [] + workstations = [] + runs = [] + + if selected: + # Synchronize latest sales evidence run states opportunistically. + runs = list(db.execute( + select(AccountingSalesHistoricalRun).where( + AccountingSalesHistoricalRun.tenant_id == scope.tenant_id, + AccountingSalesHistoricalRun.client_id == selected.id, + ).order_by(AccountingSalesHistoricalRun.id.desc()).limit(20) + ).scalars().all()) + for run in runs: + if run.status not in {"completed", "failed"}: + ingest_completed_sales_run(db, run) + + 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] + summary = learning_summary(db, tenant_id=scope.tenant_id, client_id=selected.id) + guids = available_tally_guids(db, scope.tenant_id, selected.id) + workstations = _workstations(db, scope.tenant_id) + + return templates.TemplateResponse( + "modules/accounting/templates/accounting/sales_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": "Customer & Sales Ledger Intelligence", + "clients": clients, + "selected_client": selected, + "mappings": mappings, + "evidence": evidence, + "summary": summary, + "tally_guids": guids, + "workstations": workstations, + "runs": runs, + "message": message, + "error": error, + }, + ) + finally: + db.close() + + +@router.post("/collect") +def collect( + request: Request, + client_id: int = Form(...), + workstation_id: int = Form(...), + tally_guid: str = Form(...), + date_from: str = Form(""), + date_to: str = Form(""), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, denied = _require_partner(request, db, "accounting.learning.manage") + if denied: + return denied + + client, _, scope = _find_visible_client(db, request, user, client_id) + if not client: + return _go(error="Client is not visible.") + + workstation = db.get(ERPWorkstationAgent, int(workstation_id)) + if ( + not workstation + or int(workstation.tenant_id) != int(scope.tenant_id) + or not workstation.is_active + or not workstation.tally_connected + ): + 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: + date_from = (date.today() - timedelta(days=730)).isoformat() + if not date_to: + date_to = date.today().isoformat() + + payload = { + "tenant_id": scope.tenant_id, + "client_id": client.id, + "tally_guid": tally_guid, + "date_from": date_from, + "date_to": date_to, + "voucher_scope": "sales", + } + 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, + ) + + run = AccountingSalesHistoricalRun( + tenant_id=scope.tenant_id, + client_id=client.id, + tally_guid=tally_guid, + company_name=guid_options.get(tally_guid, ""), + workstation_agent_id=workstation.id, + agent_job_id=job.id, + date_from=date_from, + date_to=date_to, + status="queued", + requested_by_user_id=user.id, + ) + db.add(run) + db.commit() + + return _go( + client.id, + message="Historical Tally Sales evidence collection queued.", + ) + except Exception as exc: + db.rollback() + return _go(client_id, error=str(exc)) + finally: + db.close() diff --git a/app/modules/accounting/sales_service.py b/app/modules/accounting/sales_service.py index a5ff338..a8db771 100644 --- a/app/modules/accounting/sales_service.py +++ b/app/modules/accounting/sales_service.py @@ -12,6 +12,7 @@ from sqlalchemy import func, select from app.modules.accounting.sales_models import AccountingSalesIncomeTransaction from app.modules.accounting.sales_taxonomy import ensure_sales_income_taxonomy, sales_income_natures +from app.modules.accounting.sales_learning_service import save_review_mapping, suggestion_for_customer from app.modules.accounting.taxonomy_models import AccountingNature @@ -284,7 +285,22 @@ def import_sales_file( duplicates += 1 continue - nature, confidence, reason = _classify(data, nature_by_code) + learned = suggestion_for_customer( + db, + tenant_id=tenant_id, + client_id=client_id, + customer_name=data["customer_name"], + customer_gstin=data["customer_gstin"], + ) + if learned and learned.get("nature") is not None: + nature = learned["nature"] + confidence = int(learned["confidence"]) + reason = learned["reason"] + learned_ledger = learned.get("ledger_name") or "" + else: + nature, confidence, reason = _classify(data, nature_by_code) + learned_ledger = learned.get("ledger_name") if learned else "" + record = AccountingSalesIncomeTransaction( tenant_id=int(tenant_id), client_id=int(client_id), @@ -309,6 +325,7 @@ def import_sales_file( cess=data["cess"], invoice_value=data["invoice_value"], suggested_nature_id=nature.id, + suggested_ledger_name=learned_ledger or "", suggested_confidence=int(confidence), suggestion_reason=reason, review_status="suggested" if confidence >= 60 else "review_required", @@ -421,4 +438,17 @@ def review_sales( db.add(row) db.commit() db.refresh(row) + + save_review_mapping( + db, + tenant_id=tenant_id, + client_id=client_id, + customer_name=row.customer_name, + customer_gstin=row.customer_gstin, + nature_id=row.final_nature_id, + sales_ledger_name=row.final_ledger_name, + user_id=user_id, + amount=row.invoice_value, + invoice_date=row.invoice_date, + ) return row diff --git a/app/modules/accounting/templates/accounting/sales_income.html b/app/modules/accounting/templates/accounting/sales_income.html index 6be2507..d356f45 100644 --- a/app/modules/accounting/templates/accounting/sales_income.html +++ b/app/modules/accounting/templates/accounting/sales_income.html @@ -5,9 +5,12 @@

Accounting · Sales & Income · Phase 14

Sales & Income Foundation

-

Normalized outward transaction register for GSTR-1/exported sales data, customer/GSTIN identity, income-nature classification and reviewer-confirmed sales ledger mapping. Phase 14 is review-only; Tally sales posting is not enabled yet.

+

Normalized outward transaction register with customer/GSTIN identity, historical customer/ledger learning, income-nature ranking and reviewer-confirmed sales ledger mapping. Phase 14 is review-only; Tally sales posting is not enabled yet.

+
+
+ Customer Intelligence + Back to Accounting
- Back to Accounting {% if message %}
{{ message }}
{% endif %} diff --git a/app/modules/accounting/templates/accounting/sales_learning.html b/app/modules/accounting/templates/accounting/sales_learning.html new file mode 100644 index 0000000..8738cd3 --- /dev/null +++ b/app/modules/accounting/templates/accounting/sales_learning.html @@ -0,0 +1,92 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Sales & Income · Phase 15

+

Customer & Sales Ledger Intelligence

+

Learn how each client historically treats customers and Sales/Income ledgers. Purchase historical evidence remains in its existing store; this page keeps Sales evidence separate.

+
+ Sales & Income Register +
+ + {% if message %}
{{ message }}
{% endif %} + {% if error %}
{{ error }}
{% endif %} + +
+
+ +
+
+
+ + {% if selected_client %} +
+ {% for label,value in [('Customer mappings',summary.mapping_count),('Historical Sales pairs',summary.historical_rows),('Reviewed mappings',summary.review_confirmed),('High confidence',summary.high_confidence)] %} +
{{ label }}
{{ value }}
+ {% endfor %} +
+ +
+

Collect Historical Sales Evidence from Tally

+

Read-only. Uses the existing durable Local Agent job queue and only Sales voucher history. It does not create or alter Tally vouchers.

+
+ + + + + + + +
+
+ +
+

Learned Customer Mappings

Reviewer confirmations take priority over historical evidence.

+
+ + + + {% for m in mappings %} + + {% else %}{% endfor %} + +
CustomerGSTINTally CustomerSales LedgerEvidenceConfidence
{{ m.customer_name }}{{ m.customer_gstin or '—' }}{{ m.tally_customer_ledger_name or '—' }}{{ m.sales_ledger_name or '—' }}{{ m.evidence_count }} · {{ m.source }}{{ m.confidence_percent }}%
Mappings will appear as Sales & Income rows are reviewed.
+
+
+ +
+

Historical Tally Sales Evidence

+
+ + + + {% for e in evidence %} + + {% else %}{% endfor %} + +
Customer ledgerCounter / Sales ledgerVoucherCountAmountLast used
{{ e.party_ledger_name }}{{ e.counter_ledger_name }}{{ e.voucher_type_name }}{{ e.voucher_count }}₹{{ '%.2f'|format(e.absolute_amount_total) }}{{ e.last_voucher_date or '—' }}
No historical Sales evidence collected yet.
+
+
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html index 2f1eba6..3178781 100644 --- a/app/modules/accounting/templates/accounting/tally.html +++ b/app/modules/accounting/templates/accounting/tally.html @@ -42,6 +42,7 @@
{% if selected_client %}Sales & Income Foundation{% else %}Sales & Income Foundation{% endif %} + {% if selected_client %}Customer & Sales Ledger Intelligence{% else %}Customer & Sales Ledger Intelligence{% endif %}
diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index df14643..7535c62 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -4,7 +4,7 @@ import io from pathlib import Path import zipfile -ERP_LOCAL_AGENT_VERSION = "1.14.0" +ERP_LOCAL_AGENT_VERSION = "1.15.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) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index 5b70321..161fde1 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.14.0" +__version__ = "1.15.0" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py index 455358a..86a8822 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py @@ -1091,8 +1091,15 @@ class LocalAccountingStore: 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": + scope = str(voucher_scope or "purchase").strip().lower() + if scope == "purchase": where.append("LOWER(v.voucher_type_name) LIKE '%purchase%'") + elif scope == "sales": + where.append("LOWER(v.voucher_type_name) LIKE '%sales%'") + elif scope in {"all", "any"}: + pass + else: + raise ValueError("Historical evidence voucher_scope must be purchase, sales or all.") if date_from: where.append("v.voucher_date>=?"); params.append(str(date_from)) if date_to: diff --git a/app/ui/app.py b/app/ui/app.py index 92a2153..08d8bda 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -48,6 +48,7 @@ from app.modules.accounting.bank_ui import router as accounting_bank_ui_router from app.modules.accounting.ai_ui import router as accounting_ai_ui_router from app.modules.accounting.internal_model_ui import router as accounting_internal_model_ui_router from app.modules.accounting.sales_ui import router as accounting_sales_income_ui_router +from app.modules.accounting.sales_learning_ui import router as accounting_sales_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 @@ -82,6 +83,7 @@ def mount_ui(app: FastAPI) -> None: app.include_router(accounting_ai_ui_router) app.include_router(accounting_internal_model_ui_router) app.include_router(accounting_sales_income_ui_router) + app.include_router(accounting_sales_learning_ui_router) app.include_router(work_tracker_ui_router) app.include_router(billing_ui_router) app.include_router(platform_billing_ui_router)