Add Phase 15 customer and sales ledger intelligence
This commit is contained in:
@@ -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),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user