Add Phase 15 customer and sales ledger intelligence
This commit is contained in:
@@ -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),
|
||||||
|
}
|
||||||
@@ -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_models import AccountingSalesIncomeTransaction
|
||||||
from app.modules.accounting.sales_taxonomy import ensure_sales_income_taxonomy, sales_income_natures
|
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
|
from app.modules.accounting.taxonomy_models import AccountingNature
|
||||||
|
|
||||||
|
|
||||||
@@ -284,7 +285,22 @@ def import_sales_file(
|
|||||||
duplicates += 1
|
duplicates += 1
|
||||||
continue
|
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(
|
record = AccountingSalesIncomeTransaction(
|
||||||
tenant_id=int(tenant_id),
|
tenant_id=int(tenant_id),
|
||||||
client_id=int(client_id),
|
client_id=int(client_id),
|
||||||
@@ -309,6 +325,7 @@ def import_sales_file(
|
|||||||
cess=data["cess"],
|
cess=data["cess"],
|
||||||
invoice_value=data["invoice_value"],
|
invoice_value=data["invoice_value"],
|
||||||
suggested_nature_id=nature.id,
|
suggested_nature_id=nature.id,
|
||||||
|
suggested_ledger_name=learned_ledger or "",
|
||||||
suggested_confidence=int(confidence),
|
suggested_confidence=int(confidence),
|
||||||
suggestion_reason=reason,
|
suggestion_reason=reason,
|
||||||
review_status="suggested" if confidence >= 60 else "review_required",
|
review_status="suggested" if confidence >= 60 else "review_required",
|
||||||
@@ -421,4 +438,17 @@ def review_sales(
|
|||||||
db.add(row)
|
db.add(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
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
|
return row
|
||||||
|
|||||||
@@ -5,9 +5,12 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Accounting · Sales & Income · Phase 14</p>
|
<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>
|
<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>
|
</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>
|
</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 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>
|
||||||
<div class="flex flex-wrap gap-2">
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import io
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
ERP_LOCAL_AGENT_VERSION = "1.14.0"
|
ERP_LOCAL_AGENT_VERSION = "1.15.0"
|
||||||
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
||||||
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
||||||
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
_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"
|
AGENT_NAME = "ERP Local Agent"
|
||||||
|
|||||||
@@ -1091,8 +1091,15 @@ class LocalAccountingStore:
|
|||||||
mapping = self.get_active_mapping_by_guid(client_id, guid)
|
mapping = self.get_active_mapping_by_guid(client_id, guid)
|
||||||
params: list = [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))"]
|
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%'")
|
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:
|
if date_from:
|
||||||
where.append("v.voucher_date>=?"); params.append(str(date_from))
|
where.append("v.voucher_date>=?"); params.append(str(date_from))
|
||||||
if date_to:
|
if date_to:
|
||||||
|
|||||||
@@ -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.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.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_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.registrations.ui import router as registrations_ui_router
|
||||||
from app.modules.credential_vault.ui import router as credential_vault_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
|
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_ai_ui_router)
|
||||||
app.include_router(accounting_internal_model_ui_router)
|
app.include_router(accounting_internal_model_ui_router)
|
||||||
app.include_router(accounting_sales_income_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(work_tracker_ui_router)
|
||||||
app.include_router(billing_ui_router)
|
app.include_router(billing_ui_router)
|
||||||
app.include_router(platform_billing_ui_router)
|
app.include_router(platform_billing_ui_router)
|
||||||
|
|||||||
Reference in New Issue
Block a user