Add Phase 15 customer and sales ledger intelligence

This commit is contained in:
A R R R Associates
2026-08-22 22:43:44 +05:30
parent efc12e20d3
commit 230fe7ffc5
12 changed files with 879 additions and 6 deletions
@@ -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")
@@ -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),
)
@@ -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),
}
+206
View File
@@ -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()
+31 -1
View File
@@ -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
@@ -5,9 +5,12 @@
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Accounting · Sales & Income · Phase 14</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Sales & Income Foundation</h1>
<p class="mt-1 max-w-4xl text-sm text-slate-500">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.</p>
<p class="mt-1 max-w-4xl text-sm text-slate-500">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.</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/tools/accounting/sales-learning{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-800">Customer Intelligence</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">Back to Accounting</a>
</div>
<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">Back to Accounting</a>
</div>
{% if message %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">{{ message }}</div>{% endif %}
@@ -0,0 +1,92 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-emerald-700">Sales & Income · Phase 15</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Customer & Sales Ledger Intelligence</h1>
<p class="mt-1 max-w-4xl text-sm text-slate-500">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.</p>
</div>
<a href="/tools/accounting/sales-income{% 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">Sales & Income Register</a>
</div>
{% if message %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">{{ message }}</div>{% endif %}
{% if error %}<div class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-800">{{ error }}</div>{% endif %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<form method="get" class="grid gap-4 md:grid-cols-[1fr_auto]">
<label class="text-sm font-semibold">ERP Client
<select name="client_id" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2">
<option value="">Select client</option>
{% for c in clients %}<option value="{{ c.id }}" {% if selected_client and selected_client.id==c.id %}selected{% endif %}>{{ c.client_name }}</option>{% endfor %}
</select>
</label>
<div class="flex items-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Open Intelligence</button></div>
</form>
</section>
{% if selected_client %}
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{% 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)] %}
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">{{ label }}</div><div class="mt-2 text-2xl font-semibold">{{ value }}</div></div>
{% endfor %}
</section>
<section class="rounded-2xl bg-white p-5 shadow-soft">
<h2 class="font-semibold">Collect Historical Sales Evidence from Tally</h2>
<p class="mt-1 text-sm text-slate-500">Read-only. Uses the existing durable Local Agent job queue and only Sales voucher history. It does not create or alter Tally vouchers.</p>
<form method="post" action="/tools/accounting/sales-learning/collect" class="mt-4 grid gap-3 md:grid-cols-5">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<label class="text-xs font-semibold md:col-span-2">Mapped Tally Company
<select name="tally_guid" required class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
<option value="">Select company</option>
{% for guid,name in tally_guids %}<option value="{{ guid }}">{{ name }}</option>{% endfor %}
</select>
</label>
<label class="text-xs font-semibold">Workstation
<select name="workstation_id" required class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
<option value="">Select</option>
{% for ws in workstations %}<option value="{{ ws.id }}">{{ ws.machine_name }}</option>{% endfor %}
</select>
</label>
<label class="text-xs font-semibold">From
<input type="date" name="date_from" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
</label>
<label class="text-xs font-semibold">To
<input type="date" name="date_to" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
</label>
<button class="rounded-lg bg-slate-900 px-3 py-2 text-sm font-semibold text-white md:col-span-5">Collect Sales History</button>
</form>
</section>
<section class="rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-200 px-5 py-4"><h2 class="font-semibold">Learned Customer Mappings</h2><p class="text-sm text-slate-500">Reviewer confirmations take priority over historical evidence.</p></div>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead><tr class="border-b bg-slate-50 text-left text-xs uppercase text-slate-500"><th class="px-3 py-3">Customer</th><th class="px-3 py-3">GSTIN</th><th class="px-3 py-3">Tally Customer</th><th class="px-3 py-3">Sales Ledger</th><th class="px-3 py-3">Evidence</th><th class="px-3 py-3">Confidence</th></tr></thead>
<tbody>
{% for m in mappings %}
<tr class="border-b border-slate-100"><td class="px-3 py-3 font-semibold">{{ m.customer_name }}</td><td class="px-3 py-3">{{ m.customer_gstin or '—' }}</td><td class="px-3 py-3">{{ m.tally_customer_ledger_name or '—' }}</td><td class="px-3 py-3">{{ m.sales_ledger_name or '—' }}</td><td class="px-3 py-3">{{ m.evidence_count }} · {{ m.source }}</td><td class="px-3 py-3">{{ m.confidence_percent }}%</td></tr>
{% else %}<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">Mappings will appear as Sales & Income rows are reviewed.</td></tr>{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-200 px-5 py-4"><h2 class="font-semibold">Historical Tally Sales Evidence</h2></div>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead><tr class="border-b bg-slate-50 text-left text-xs uppercase text-slate-500"><th class="px-3 py-3">Customer ledger</th><th class="px-3 py-3">Counter / Sales ledger</th><th class="px-3 py-3">Voucher</th><th class="px-3 py-3">Count</th><th class="px-3 py-3">Amount</th><th class="px-3 py-3">Last used</th></tr></thead>
<tbody>
{% for e in evidence %}
<tr class="border-b border-slate-100"><td class="px-3 py-3 font-semibold">{{ e.party_ledger_name }}</td><td class="px-3 py-3">{{ e.counter_ledger_name }}</td><td class="px-3 py-3">{{ e.voucher_type_name }}</td><td class="px-3 py-3">{{ e.voucher_count }}</td><td class="px-3 py-3">₹{{ '%.2f'|format(e.absolute_amount_total) }}</td><td class="px-3 py-3">{{ e.last_voucher_date or '—' }}</td></tr>
{% else %}<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No historical Sales evidence collected yet.</td></tr>{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
</div>
{% endblock %}
@@ -42,6 +42,7 @@
</div>
<div class="flex flex-wrap gap-2">
{% if selected_client %}<a href="/tools/accounting/sales-income?client_id={{ selected_client.id }}" class="rounded-xl bg-emerald-700 px-3 py-2 text-sm font-semibold text-white">Sales & Income Foundation</a>{% else %}<a href="/tools/accounting/sales-income" class="rounded-xl bg-emerald-700 px-3 py-2 text-sm font-semibold text-white">Sales & Income Foundation</a>{% endif %}
{% if selected_client %}<a href="/tools/accounting/sales-learning?client_id={{ selected_client.id }}" class="rounded-xl border border-emerald-300 bg-white px-3 py-2 text-sm font-semibold text-emerald-800">Customer & Sales Ledger Intelligence</a>{% else %}<a href="/tools/accounting/sales-learning" class="rounded-xl border border-emerald-300 bg-white px-3 py-2 text-sm font-semibold text-emerald-800">Customer & Sales Ledger Intelligence</a>{% endif %}
</div>
</div>
+1 -1
View File
@@ -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)
@@ -1,2 +1,2 @@
__version__ = "1.14.0"
__version__ = "1.15.0"
AGENT_NAME = "ERP Local Agent"
@@ -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:
+2
View File
@@ -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)