From 7d11236eed12369e3b4182995f2e05566327f8e1 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Sun, 23 Aug 2026 13:35:37 +0530 Subject: [PATCH] Add Phase 16 Chart of Accounts and ledger group intelligence --- .../20260823_chart_of_accounts_phase16.py | 99 ++++ app/modules/accounting/chart_models.py | 138 +++++ app/modules/accounting/chart_service.py | 514 ++++++++++++++++++ app/modules/accounting/chart_ui.py | 348 ++++++++++++ .../accounting/chart_of_accounts.html | 270 +++++++++ .../templates/accounting/tally.html | 11 + app/modules/documents/agent_package.py | 2 +- .../erp_local_agent/__init__.py | 2 +- .../erp_local_agent/accounting_store.py | 55 ++ .../erp_local_agent/commands.py | 19 +- .../erp_local_agent/tally.py | 6 +- app/ui/app.py | 2 + 12 files changed, 1462 insertions(+), 4 deletions(-) create mode 100644 alembic/versions/20260823_chart_of_accounts_phase16.py create mode 100644 app/modules/accounting/chart_models.py create mode 100644 app/modules/accounting/chart_service.py create mode 100644 app/modules/accounting/chart_ui.py create mode 100644 app/modules/accounting/templates/accounting/chart_of_accounts.html diff --git a/alembic/versions/20260823_chart_of_accounts_phase16.py b/alembic/versions/20260823_chart_of_accounts_phase16.py new file mode 100644 index 0000000..6c18311 --- /dev/null +++ b/alembic/versions/20260823_chart_of_accounts_phase16.py @@ -0,0 +1,99 @@ +"""Phase 16 Chart of Accounts / Ledger & Group intelligence. + +Revision ID: 20260823_chart_of_accounts_p16 +Revises: 20260822_sales_customer_learning_p15 +""" +from alembic import op +import sqlalchemy as sa + + +revision = "20260823_chart_of_accounts_p16" +down_revision = "20260822_sales_customer_learning_p15" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "accounting_chart_sync_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), + sa.Column("company_name", sa.String(255), nullable=False, server_default=""), + sa.Column("status", sa.String(30), nullable=False, server_default="completed"), + sa.Column("group_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("ledger_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("classified_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("review_count", sa.Integer(), nullable=False, server_default="0"), + 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()), + ) + for name in ("tenant_id", "client_id", "tally_guid", "status", "created_at_utc"): + op.create_index(f"ix_accounting_chart_sync_runs_{name}", "accounting_chart_sync_runs", [name]) + + op.create_table( + "accounting_chart_groups", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("tally_guid", sa.String(120), nullable=False), + sa.Column("company_name", sa.String(255), nullable=False, server_default=""), + sa.Column("master_guid", sa.String(120), nullable=False, server_default=""), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("parent_name", sa.String(255), nullable=False, server_default=""), + sa.Column("root_group_name", sa.String(255), nullable=False, server_default=""), + sa.Column("depth", sa.Integer(), nullable=False, server_default="0"), + sa.Column("reserved_name", sa.String(255), nullable=False, server_default=""), + sa.Column("is_revenue", sa.String(30), nullable=False, server_default=""), + sa.Column("is_deemed_positive", sa.String(30), nullable=False, server_default=""), + sa.Column("source_payload_json", sa.Text(), nullable=False, server_default="{}"), + sa.Column("synced_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("tenant_id", "client_id", "tally_guid", "name", name="uq_accounting_chart_group_name"), + ) + for name in ("tenant_id", "client_id", "tally_guid", "master_guid", "name", "parent_name", "root_group_name", "synced_at_utc"): + op.create_index(f"ix_accounting_chart_groups_{name}", "accounting_chart_groups", [name]) + + op.create_table( + "accounting_chart_ledgers", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("tally_guid", sa.String(120), nullable=False), + sa.Column("company_name", sa.String(255), nullable=False, server_default=""), + sa.Column("master_guid", sa.String(120), nullable=False, server_default=""), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("parent_group_name", sa.String(255), nullable=False, server_default=""), + sa.Column("root_group_name", sa.String(255), nullable=False, server_default=""), + sa.Column("opening_balance", sa.Float(), nullable=False, server_default="0"), + sa.Column("closing_balance", sa.Float(), nullable=False, server_default="0"), + sa.Column("is_billwise_on", sa.String(30), nullable=False, server_default=""), + sa.Column("tax_type", sa.String(80), nullable=False, server_default=""), + sa.Column("gst_applicable", sa.String(80), nullable=False, server_default=""), + sa.Column("gst_registration_type", sa.String(80), nullable=False, server_default=""), + sa.Column("gst_type_of_supply", sa.String(80), nullable=False, server_default=""), + sa.Column("hsn_code", sa.String(40), nullable=False, server_default=""), + sa.Column("party_gstin", sa.String(20), nullable=False, server_default=""), + sa.Column("suggested_role_code", sa.String(50), nullable=False, server_default="REVIEW"), + sa.Column("suggested_confidence", sa.Integer(), nullable=False, server_default="0"), + sa.Column("suggestion_reason", sa.Text(), nullable=False, server_default=""), + sa.Column("manual_role_code", sa.String(50), nullable=False, server_default=""), + sa.Column("manual_note", sa.Text(), nullable=False, server_default=""), + sa.Column("manual_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("manual_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("source_payload_json", sa.Text(), nullable=False, server_default="{}"), + sa.Column("synced_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("tenant_id", "client_id", "tally_guid", "name", name="uq_accounting_chart_ledger_name"), + ) + for name in ( + "tenant_id", "client_id", "tally_guid", "master_guid", "name", + "parent_group_name", "root_group_name", "party_gstin", + "suggested_role_code", "manual_role_code", "synced_at_utc", + ): + op.create_index(f"ix_accounting_chart_ledgers_{name}", "accounting_chart_ledgers", [name]) + + +def downgrade(): + op.drop_table("accounting_chart_ledgers") + op.drop_table("accounting_chart_groups") + op.drop_table("accounting_chart_sync_runs") diff --git a/app/modules/accounting/chart_models.py b/app/modules/accounting/chart_models.py new file mode 100644 index 0000000..f739116 --- /dev/null +++ b/app/modules/accounting/chart_models.py @@ -0,0 +1,138 @@ +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 AccountingChartSyncRun(CommonBase): + __tablename__ = "accounting_chart_sync_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, index=True) + company_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + status: Mapped[str] = mapped_column(String(30), nullable=False, default="completed", index=True) + group_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + ledger_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + classified_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + review_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + 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, + ) + + +class AccountingChartGroup(CommonBase): + __tablename__ = "accounting_chart_groups" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "client_id", + "tally_guid", + "name", + name="uq_accounting_chart_group_name", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column( + ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True + ) + client_id: Mapped[int] = mapped_column( + ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True + ) + tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + company_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + master_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="", index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + parent_name: Mapped[str] = mapped_column(String(255), nullable=False, default="", index=True) + root_group_name: Mapped[str] = mapped_column(String(255), nullable=False, default="", index=True) + depth: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + reserved_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + is_revenue: Mapped[str] = mapped_column(String(30), nullable=False, default="") + is_deemed_positive: Mapped[str] = mapped_column(String(30), nullable=False, default="") + source_payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + synced_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + index=True, + ) + + +class AccountingChartLedger(CommonBase): + __tablename__ = "accounting_chart_ledgers" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "client_id", + "tally_guid", + "name", + name="uq_accounting_chart_ledger_name", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column( + ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True + ) + client_id: Mapped[int] = mapped_column( + ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True + ) + tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + company_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + master_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="", index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + parent_group_name: Mapped[str] = mapped_column(String(255), nullable=False, default="", index=True) + root_group_name: Mapped[str] = mapped_column(String(255), nullable=False, default="", index=True) + + opening_balance: Mapped[float] = mapped_column(Float, nullable=False, default=0) + closing_balance: Mapped[float] = mapped_column(Float, nullable=False, default=0) + is_billwise_on: Mapped[str] = mapped_column(String(30), nullable=False, default="") + tax_type: Mapped[str] = mapped_column(String(80), nullable=False, default="") + gst_applicable: Mapped[str] = mapped_column(String(80), nullable=False, default="") + gst_registration_type: Mapped[str] = mapped_column(String(80), nullable=False, default="") + gst_type_of_supply: Mapped[str] = mapped_column(String(80), nullable=False, default="") + hsn_code: Mapped[str] = mapped_column(String(40), nullable=False, default="") + party_gstin: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True) + + suggested_role_code: Mapped[str] = mapped_column(String(50), nullable=False, default="REVIEW", index=True) + suggested_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + suggestion_reason: Mapped[str] = mapped_column(Text, nullable=False, default="") + + manual_role_code: Mapped[str] = mapped_column(String(50), nullable=False, default="", index=True) + manual_note: Mapped[str] = mapped_column(Text, nullable=False, default="") + manual_by_user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + manual_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + source_payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + synced_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + index=True, + ) diff --git a/app/modules/accounting/chart_service.py b/app/modules/accounting/chart_service.py new file mode 100644 index 0000000..89077f9 --- /dev/null +++ b/app/modules/accounting/chart_service.py @@ -0,0 +1,514 @@ +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import delete, func, or_, select + +from app.modules.accounting.chart_models import ( + AccountingChartGroup, + AccountingChartLedger, + AccountingChartSyncRun, +) + + +LEDGER_ROLES = [ + ("CUSTOMER", "Customer / Sundry Debtor"), + ("SUPPLIER", "Supplier / Sundry Creditor"), + ("SALES", "Sales"), + ("PURCHASE", "Purchase"), + ("DIRECT_INCOME", "Direct Income"), + ("OTHER_INCOME", "Other / Indirect Income"), + ("DIRECT_EXPENSE", "Direct Expense"), + ("INDIRECT_EXPENSE", "Indirect Expense"), + ("GST_INPUT", "GST Input Tax"), + ("GST_OUTPUT", "GST Output Tax"), + ("TAX_OTHER", "Other Duties & Taxes"), + ("BANK", "Bank"), + ("CASH", "Cash"), + ("FIXED_ASSET", "Fixed Asset"), + ("CURRENT_ASSET", "Current Asset"), + ("CURRENT_LIABILITY", "Current Liability"), + ("LOAN", "Loan / Borrowing"), + ("CAPITAL", "Capital"), + ("RESERVE", "Reserve / Surplus"), + ("INVENTORY", "Inventory / Stock"), + ("INVESTMENT", "Investment"), + ("ROUND_OFF", "Round Off"), + ("DISCOUNT", "Discount"), + ("FREIGHT_CHARGE", "Freight / Loading / Ancillary Charge"), + ("DEPRECIATION", "Depreciation"), + ("INTER_BRANCH", "Branch / Division"), + ("SUSPENSE", "Suspense / Review"), + ("REVIEW", "Review Required"), +] + +ROLE_LABELS = dict(LEDGER_ROLES) +VALID_ROLE_CODES = set(ROLE_LABELS) + + +def _utcnow(): + return datetime.now(timezone.utc) + + +def _s(value: Any) -> str: + return str(value or "").strip() + + +def _key(value: Any) -> str: + return re.sub(r"[^a-z0-9]+", " ", _s(value).casefold()).strip() + + +PRIMARY_ROLE_MAP = { + "sundry debtors": ("CUSTOMER", 100, "Tally root group is Sundry Debtors."), + "sundry creditors": ("SUPPLIER", 100, "Tally root group is Sundry Creditors."), + "sales accounts": ("SALES", 100, "Tally root group is Sales Accounts."), + "purchase accounts": ("PURCHASE", 100, "Tally root group is Purchase Accounts."), + "direct incomes": ("DIRECT_INCOME", 98, "Tally root group is Direct Incomes."), + "indirect incomes": ("OTHER_INCOME", 98, "Tally root group is Indirect Incomes."), + "direct expenses": ("DIRECT_EXPENSE", 98, "Tally root group is Direct Expenses."), + "indirect expenses": ("INDIRECT_EXPENSE", 98, "Tally root group is Indirect Expenses."), + "bank accounts": ("BANK", 100, "Tally root group is Bank Accounts."), + "bank od a c": ("LOAN", 96, "Tally root group is Bank OD A/c."), + "cash in hand": ("CASH", 100, "Tally root group is Cash-in-Hand."), + "fixed assets": ("FIXED_ASSET", 100, "Tally root group is Fixed Assets."), + "current assets": ("CURRENT_ASSET", 92, "Tally root group is Current Assets."), + "current liabilities": ("CURRENT_LIABILITY", 92, "Tally root group is Current Liabilities."), + "loans liability": ("LOAN", 98, "Tally root group is Loans (Liability)."), + "secured loans": ("LOAN", 98, "Tally root group is Secured Loans."), + "unsecured loans": ("LOAN", 98, "Tally root group is Unsecured Loans."), + "capital account": ("CAPITAL", 100, "Tally root group is Capital Account."), + "reserves surplus": ("RESERVE", 100, "Tally root group is Reserves & Surplus."), + "stock in hand": ("INVENTORY", 100, "Tally root group is Stock-in-Hand."), + "investments": ("INVESTMENT", 100, "Tally root group is Investments."), + "duties taxes": ("TAX_OTHER", 94, "Tally root group is Duties & Taxes."), + "branch divisions": ("INTER_BRANCH", 98, "Tally root group is Branch/Divisions."), + "suspense a c": ("SUSPENSE", 100, "Tally root group is Suspense A/c."), +} + + +def resolve_group_hierarchy(groups: list[dict]) -> dict[str, dict]: + by_name = {_key(row.get("name")): dict(row) for row in groups if _s(row.get("name"))} + resolved: dict[str, dict] = {} + + def resolve(name: str, trail: set[str] | None = None): + k = _key(name) + if not k: + return {"root_group_name": "", "depth": 0} + if k in resolved: + return resolved[k] + + trail = set(trail or set()) + if k in trail: + result = {"root_group_name": _s(name), "depth": 0} + resolved[k] = result + return result + trail.add(k) + + row = by_name.get(k) + if not row: + result = {"root_group_name": _s(name), "depth": 0} + resolved[k] = result + return result + + parent = _s(row.get("parent")) + parent_key = _key(parent) + if not parent or parent_key in {"primary", "primary group"} or parent_key == k: + result = {"root_group_name": _s(row.get("name")), "depth": 0} + else: + parent_result = resolve(parent, trail) + result = { + "root_group_name": parent_result["root_group_name"] or _s(row.get("name")), + "depth": int(parent_result["depth"]) + 1, + } + resolved[k] = result + return result + + for row in groups: + name = _s(row.get("name")) + if name: + resolve(name) + return resolved + + +def classify_ledger(ledger: dict, root_group_name: str) -> tuple[str, int, str]: + name = _key(ledger.get("name")) + parent = _key(ledger.get("parent")) + root = _key(root_group_name) + + # Strong ledger-name exceptions first. + if "round off" in name or "rounded off" in name: + return "ROUND_OFF", 100, "Ledger name identifies a round-off ledger." + if "depreciation" in name: + return "DEPRECIATION", 96, "Ledger name contains Depreciation." + if "discount" in name: + return "DISCOUNT", 88, "Ledger name contains Discount." + if any(token in name for token in ("freight", "loading", "carriage outward", "delivery charge", "transport charge")): + return "FREIGHT_CHARGE", 82, "Ledger name indicates freight/loading/ancillary charges." + + # Duties & Taxes needs input/output distinction. + if root == "duties taxes" or parent == "duties taxes": + gst_text = " ".join( + [ + name, + _key(ledger.get("tax_type")), + _key(ledger.get("gst_applicable")), + ] + ) + if any(token in gst_text for token in ("input", "itc", "input cgst", "input sgst", "input igst")): + return "GST_INPUT", 98, "Duties & Taxes ledger is identified as input GST." + if any(token in gst_text for token in ("output", "output cgst", "output sgst", "output igst")): + return "GST_OUTPUT", 98, "Duties & Taxes ledger is identified as output GST." + return "TAX_OTHER", 94, "Ledger belongs to Duties & Taxes." + + mapped = PRIMARY_ROLE_MAP.get(root) + if mapped: + return mapped + + # Useful parent-level fallback when the root could not be resolved. + mapped = PRIMARY_ROLE_MAP.get(parent) + if mapped: + code, confidence, reason = mapped + return code, min(confidence, 90), f"Parent group classification: {reason}" + + # Tally metadata fallback. + tax_type = _key(ledger.get("tax_type")) + if tax_type and tax_type not in {"not applicable", "none"}: + return "TAX_OTHER", 72, f"Tally TaxType is '{_s(ledger.get('tax_type'))}'." + + return "REVIEW", 25, "No sufficiently reliable Chart-of-Accounts rule matched this ledger." + + +def persist_chart_snapshot( + db, + *, + tenant_id: int, + client_id: int, + tally_guid: str, + company_name: str, + groups: list[dict], + ledgers: list[dict], + user_id: int, +): + tenant_id = int(tenant_id) + client_id = int(client_id) + tally_guid = _s(tally_guid) + company_name = _s(company_name) + if not tally_guid: + raise ValueError("Tally company GUID is required.") + if not company_name: + raise ValueError("Tally company name is required.") + + hierarchy = resolve_group_hierarchy(groups) + now = _utcnow() + + # Preserve manual ledger overrides across refresh. + existing_ledgers = list( + db.execute( + select(AccountingChartLedger).where( + AccountingChartLedger.tenant_id == tenant_id, + AccountingChartLedger.client_id == client_id, + AccountingChartLedger.tally_guid == tally_guid, + ) + ).scalars().all() + ) + manual = { + _key(row.name): { + "manual_role_code": row.manual_role_code, + "manual_note": row.manual_note, + "manual_by_user_id": row.manual_by_user_id, + "manual_at_utc": row.manual_at_utc, + } + for row in existing_ledgers + if row.manual_role_code + } + + db.execute( + delete(AccountingChartGroup).where( + AccountingChartGroup.tenant_id == tenant_id, + AccountingChartGroup.client_id == client_id, + AccountingChartGroup.tally_guid == tally_guid, + ) + ) + db.execute( + delete(AccountingChartLedger).where( + AccountingChartLedger.tenant_id == tenant_id, + AccountingChartLedger.client_id == client_id, + AccountingChartLedger.tally_guid == tally_guid, + ) + ) + + for row in groups: + name = _s(row.get("name")) + if not name: + continue + info = hierarchy.get(_key(name), {"root_group_name": name, "depth": 0}) + db.add( + AccountingChartGroup( + tenant_id=tenant_id, + client_id=client_id, + tally_guid=tally_guid, + company_name=company_name, + master_guid=_s(row.get("guid")), + name=name, + parent_name=_s(row.get("parent")), + root_group_name=_s(info.get("root_group_name")), + depth=int(info.get("depth") or 0), + reserved_name=_s(row.get("reserved_name")), + is_revenue=_s(row.get("is_revenue")), + is_deemed_positive=_s(row.get("is_deemed_positive")), + source_payload_json=json.dumps(row, ensure_ascii=False, separators=(",", ":")), + synced_at_utc=now, + ) + ) + + classified_count = 0 + review_count = 0 + for row in ledgers: + name = _s(row.get("name")) + if not name: + continue + parent = _s(row.get("parent")) + parent_info = hierarchy.get( + _key(parent), + {"root_group_name": parent, "depth": 0}, + ) + root_name = _s(parent_info.get("root_group_name")) or parent + role, confidence, reason = classify_ledger(row, root_name) + if role == "REVIEW": + review_count += 1 + else: + classified_count += 1 + + override = manual.get(_key(name), {}) + db.add( + AccountingChartLedger( + tenant_id=tenant_id, + client_id=client_id, + tally_guid=tally_guid, + company_name=company_name, + master_guid=_s(row.get("guid")), + name=name, + parent_group_name=parent, + root_group_name=root_name, + opening_balance=float(row.get("opening_balance") or 0), + closing_balance=float(row.get("closing_balance") or 0), + is_billwise_on=_s(row.get("is_billwise_on")), + tax_type=_s(row.get("tax_type")), + gst_applicable=_s(row.get("gst_applicable")), + gst_registration_type=_s(row.get("gst_registration_type")), + gst_type_of_supply=_s(row.get("gst_type_of_supply")), + hsn_code=_s(row.get("hsn_code")), + party_gstin=re.sub(r"[^A-Z0-9]", "", _s(row.get("party_gstin")).upper())[:15], + suggested_role_code=role, + suggested_confidence=int(confidence), + suggestion_reason=reason, + manual_role_code=_s(override.get("manual_role_code")), + manual_note=_s(override.get("manual_note")), + manual_by_user_id=override.get("manual_by_user_id"), + manual_at_utc=override.get("manual_at_utc"), + source_payload_json=json.dumps(row, ensure_ascii=False, separators=(",", ":")), + synced_at_utc=now, + ) + ) + + run = AccountingChartSyncRun( + tenant_id=tenant_id, + client_id=client_id, + tally_guid=tally_guid, + company_name=company_name, + status="completed", + group_count=len([x for x in groups if _s(x.get("name"))]), + ledger_count=len([x for x in ledgers if _s(x.get("name"))]), + classified_count=classified_count, + review_count=review_count, + requested_by_user_id=user_id, + ) + db.add(run) + db.commit() + db.refresh(run) + return run + + +def chart_summary(db, *, tenant_id: int, client_id: int, tally_guid: str = ""): + where = [ + AccountingChartLedger.tenant_id == int(tenant_id), + AccountingChartLedger.client_id == int(client_id), + ] + group_where = [ + AccountingChartGroup.tenant_id == int(tenant_id), + AccountingChartGroup.client_id == int(client_id), + ] + if tally_guid: + where.append(AccountingChartLedger.tally_guid == tally_guid) + group_where.append(AccountingChartGroup.tally_guid == tally_guid) + + ledger_count = int(db.scalar(select(func.count(AccountingChartLedger.id)).where(*where)) or 0) + group_count = int(db.scalar(select(func.count(AccountingChartGroup.id)).where(*group_where)) or 0) + manual_count = int( + db.scalar( + select(func.count(AccountingChartLedger.id)).where( + *where, + AccountingChartLedger.manual_role_code != "", + ) + ) + or 0 + ) + review_count = int( + db.scalar( + select(func.count(AccountingChartLedger.id)).where( + *where, + AccountingChartLedger.manual_role_code == "", + AccountingChartLedger.suggested_role_code == "REVIEW", + ) + ) + or 0 + ) + return { + "groups": group_count, + "ledgers": ledger_count, + "manual": manual_count, + "review": review_count, + "classified": max(0, ledger_count - review_count), + } + + +def list_ledgers( + db, + *, + tenant_id: int, + client_id: int, + tally_guid: str = "", + q: str = "", + role: str = "", + root_group: str = "", + page: int = 1, + per_page: int = 50, +): + where = [ + AccountingChartLedger.tenant_id == int(tenant_id), + AccountingChartLedger.client_id == int(client_id), + ] + if tally_guid: + where.append(AccountingChartLedger.tally_guid == tally_guid) + if q.strip(): + term = f"%{q.strip()}%" + where.append( + or_( + AccountingChartLedger.name.ilike(term), + AccountingChartLedger.parent_group_name.ilike(term), + AccountingChartLedger.root_group_name.ilike(term), + AccountingChartLedger.party_gstin.ilike(term), + ) + ) + if root_group.strip(): + where.append(AccountingChartLedger.root_group_name == root_group.strip()) + if role.strip(): + role = role.strip().upper() + where.append( + or_( + AccountingChartLedger.manual_role_code == role, + ( + AccountingChartLedger.manual_role_code == "" + ) + & (AccountingChartLedger.suggested_role_code == role), + ) + ) + + total = int(db.scalar(select(func.count(AccountingChartLedger.id)).where(*where)) or 0) + per_page = max(20, min(200, int(per_page or 50))) + pages = max(1, (total + per_page - 1) // per_page) + page = max(1, min(int(page or 1), pages)) + + rows = list( + db.execute( + select(AccountingChartLedger) + .where(*where) + .order_by( + AccountingChartLedger.root_group_name, + AccountingChartLedger.parent_group_name, + AccountingChartLedger.name, + ) + .offset((page - 1) * per_page) + .limit(per_page) + ).scalars().all() + ) + return rows, total, page, pages + + +def list_groups(db, *, tenant_id: int, client_id: int, tally_guid: str = ""): + where = [ + AccountingChartGroup.tenant_id == int(tenant_id), + AccountingChartGroup.client_id == int(client_id), + ] + if tally_guid: + where.append(AccountingChartGroup.tally_guid == tally_guid) + return list( + db.execute( + select(AccountingChartGroup) + .where(*where) + .order_by( + AccountingChartGroup.root_group_name, + AccountingChartGroup.depth, + AccountingChartGroup.name, + ) + ).scalars().all() + ) + + +def root_group_options(db, *, tenant_id: int, client_id: int, tally_guid: str = ""): + where = [ + AccountingChartLedger.tenant_id == int(tenant_id), + AccountingChartLedger.client_id == int(client_id), + AccountingChartLedger.root_group_name != "", + ] + if tally_guid: + where.append(AccountingChartLedger.tally_guid == tally_guid) + return list( + db.execute( + select(AccountingChartLedger.root_group_name) + .where(*where) + .distinct() + .order_by(AccountingChartLedger.root_group_name) + ).scalars().all() + ) + + +def set_manual_ledger_role( + db, + *, + tenant_id: int, + client_id: int, + ledger_id: int, + role_code: str, + note: str, + user_id: int, +): + row = db.execute( + select(AccountingChartLedger).where( + AccountingChartLedger.id == int(ledger_id), + AccountingChartLedger.tenant_id == int(tenant_id), + AccountingChartLedger.client_id == int(client_id), + ) + ).scalar_one_or_none() + if not row: + raise ValueError("Chart-of-Accounts ledger was not found.") + + role = _s(role_code).upper() + if role and role not in VALID_ROLE_CODES: + raise ValueError("Invalid accounting ledger role.") + + row.manual_role_code = role + row.manual_note = _s(note) + row.manual_by_user_id = int(user_id) if role else None + row.manual_at_utc = _utcnow() if role else None + db.add(row) + db.commit() + db.refresh(row) + return row + + +def effective_role(row: AccountingChartLedger) -> str: + return row.manual_role_code or row.suggested_role_code or "REVIEW" diff --git a/app/modules/accounting/chart_ui.py b/app/modules/accounting/chart_ui.py new file mode 100644 index 0000000..8152892 --- /dev/null +++ b/app/modules/accounting/chart_ui.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +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.agent_bridge import request_agent_command +from app.modules.accounting.chart_models import AccountingChartSyncRun +from app.modules.accounting.chart_service import ( + LEDGER_ROLES, + ROLE_LABELS, + chart_summary, + effective_role, + list_groups, + list_ledgers, + persist_chart_snapshot, + root_group_options, + set_manual_ledger_role, +) +from app.modules.accounting.ui import ( + _accounting_storage_payload, + _find_visible_client, + _node_online, + _require_partner, + _visible_clients, +) +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.documents.services import get_active_storage_node_for_branch + +router = APIRouter( + prefix="/tools/accounting/chart-of-accounts", + tags=["accounting-chart-of-accounts-ui"], +) + + +def _go(client_id=0, message="", error="", **filters): + q = {} + if client_id: + q["client_id"] = client_id + if message: + q["message"] = message[:350] + if error: + q["error"] = error[:350] + for key, value in filters.items(): + if value not in (None, "", "all"): + q[key] = value + return RedirectResponse( + "/tools/accounting/chart-of-accounts" + ("?" + urlencode(q) if q else ""), + status_code=303, + ) + + +@router.get("") +def page( + request: Request, + client_id: int | None = None, + tally_guid: str = "", + view: str = "ledgers", + q: str = "", + role: str = "", + root_group: str = "", + page: int = 1, + per_page: int = 50, + message: str = "", + error: str = "", +): + db = CommonSessionLocal() + try: + user, denied = _require_partner(request, db, "accounting.tally.view") + if denied: + return denied + + clients, scope = _visible_clients(db, request, user) + selected = next( + (client for client in clients if client_id and int(client.id) == int(client_id)), + None, + ) + + node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) + online = bool(node and _node_online(node)) + tally = None + accounting = None + command_error = error or "" + + if selected and node and online: + try: + response = request_agent_command( + node.node_code, + "phase6_status", + _accounting_storage_payload(selected), + timeout_seconds=20, + ) + if response.get("ok"): + result = response.get("result") or {} + tally = result.get("tally") + accounting = result.get("accounting") + else: + command_error = str(response.get("error") or "Local Agent status failed.") + except Exception as exc: + command_error = str(exc) + + mapped_guids = set() + mapping_names = {} + if accounting: + for mapping in accounting.get("mappings") or []: + guid = str(mapping.get("tally_guid") or "").strip() + if guid: + mapped_guids.add(guid) + mapping_names[guid] = str(mapping.get("company_name") or "").strip() + + if tally_guid and tally_guid not in mapped_guids: + tally_guid = "" + + available_mappings = [ + { + "guid": guid, + "name": mapping_names.get(guid, guid), + "currently_loaded": bool( + any( + str(c.get("guid") or "").strip() == guid + for c in ((tally or {}).get("companies") or []) + ) + ), + } + for guid in sorted(mapped_guids, key=lambda g: mapping_names.get(g, g).casefold()) + ] + + if not tally_guid and len(available_mappings) == 1: + tally_guid = available_mappings[0]["guid"] + + summary = {"groups": 0, "ledgers": 0, "manual": 0, "review": 0, "classified": 0} + ledgers = [] + groups = [] + total = 0 + pages = 1 + roots = [] + last_sync = None + + if selected: + summary = chart_summary( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + tally_guid=tally_guid, + ) + roots = root_group_options( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + tally_guid=tally_guid, + ) + if view == "groups": + groups = list_groups( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + tally_guid=tally_guid, + ) + else: + ledgers, total, page, pages = list_ledgers( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + tally_guid=tally_guid, + q=q, + role=role, + root_group=root_group, + page=page, + per_page=per_page, + ) + + sync_stmt = select(AccountingChartSyncRun).where( + AccountingChartSyncRun.tenant_id == scope.tenant_id, + AccountingChartSyncRun.client_id == selected.id, + ) + if tally_guid: + sync_stmt = sync_stmt.where(AccountingChartSyncRun.tally_guid == tally_guid) + last_sync = db.execute( + sync_stmt.order_by(AccountingChartSyncRun.id.desc()).limit(1) + ).scalar_one_or_none() + + return templates.TemplateResponse( + "modules/accounting/templates/accounting/chart_of_accounts.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": "Chart of Accounts", + "clients": clients, + "selected_client": selected, + "node_online": online, + "tally": tally, + "accounting": accounting, + "available_mappings": available_mappings, + "selected_tally_guid": tally_guid, + "summary": summary, + "ledgers": ledgers, + "groups": groups, + "root_groups": roots, + "role_options": LEDGER_ROLES, + "role_labels": ROLE_LABELS, + "effective_role": effective_role, + "view": view if view in {"ledgers", "groups"} else "ledgers", + "q": q, + "role_filter": role, + "root_group_filter": root_group, + "page": page, + "pages": pages, + "per_page": per_page, + "total": total, + "last_sync": last_sync, + "message": message, + "error": command_error, + }, + ) + finally: + db.close() + + +@router.post("/sync") +def sync_chart( + request: Request, + client_id: int = Form(...), + tally_guid: str = Form(...), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, denied = _require_partner(request, db, "accounting.tally.sync_masters") + if denied: + return denied + + client, _clients, scope = _find_visible_client(db, request, user, client_id) + if not client: + return _go(error="Client is not visible.") + + node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) + if not node or not _node_online(node): + return _go(client.id, error="ERP Local Agent is offline for the active branch.") + + response = request_agent_command( + node.node_code, + "accounting_chart_of_accounts", + { + **_accounting_storage_payload(client), + "tally_guid": str(tally_guid or "").strip(), + "requested_by_user_id": int(user.id), + }, + timeout_seconds=180, + ) + if not response.get("ok"): + raise RuntimeError(str(response.get("error") or "Chart of Accounts synchronization failed.")) + + result = response.get("result") or {} + chart = result.get("chart_of_accounts") or {} + groups = chart.get("groups") or [] + ledgers = chart.get("ledgers") or [] + company_name = str( + chart.get("company_name") + or (result.get("sync") or {}).get("company_name") + or "" + ).strip() + + run = persist_chart_snapshot( + db, + tenant_id=scope.tenant_id, + client_id=client.id, + tally_guid=str(tally_guid or "").strip(), + company_name=company_name, + groups=groups, + ledgers=ledgers, + user_id=user.id, + ) + + return _go( + client.id, + tally_guid=tally_guid, + message=( + f"Chart of Accounts refreshed: {run.group_count} group(s), " + f"{run.ledger_count} ledger(s)." + ), + ) + except Exception as exc: + db.rollback() + return _go(client_id, tally_guid=tally_guid, error=str(exc)) + finally: + db.close() + + +@router.post("/ledger/{ledger_id}/role") +def update_ledger_role( + request: Request, + ledger_id: int, + client_id: int = Form(...), + tally_guid: str = Form(""), + role_code: str = Form(""), + note: str = Form(""), + q: str = Form(""), + role_filter: str = Form(""), + root_group: str = Form(""), + page: int = Form(1), + per_page: int = Form(50), + 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, _clients, scope = _find_visible_client(db, request, user, client_id) + if not client: + return _go(error="Client is not visible.") + + set_manual_ledger_role( + db, + tenant_id=scope.tenant_id, + client_id=client.id, + ledger_id=ledger_id, + role_code=role_code, + note=note, + user_id=user.id, + ) + + return _go( + client.id, + tally_guid=tally_guid, + q=q, + role=role_filter, + root_group=root_group, + page=page, + per_page=per_page, + message="Ledger role updated.", + ) + except Exception as exc: + db.rollback() + return _go(client_id, tally_guid=tally_guid, error=str(exc)) + finally: + db.close() diff --git a/app/modules/accounting/templates/accounting/chart_of_accounts.html b/app/modules/accounting/templates/accounting/chart_of_accounts.html new file mode 100644 index 0000000..8910ebf --- /dev/null +++ b/app/modules/accounting/templates/accounting/chart_of_accounts.html @@ -0,0 +1,270 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Accounting Foundation · Phase 16

+

Chart of Accounts · Groups & Ledgers

+

+ Read the complete mapped Tally Group and Ledger master first, resolve every ledger through its parent-group hierarchy, and maintain a client-specific accounting role before using voucher history for Purchase/Sales classification. +

+
+ Back to Tally Accounting +
+ + {% if message %}
{{ message }}
{% endif %} + {% if error %}
{{ error }}
{% endif %} + +
+
+ + + + +
+
+
+ + {% if selected_client %} +
+
+
+

Refresh from Tally Group & Ledger Master

+

+ This is read-only against Tally. It refreshes the existing local .act master snapshot and copies Groups/Ledgers into the ERP Chart of Accounts for classification and review. +

+ {% if last_sync %} +

Last ERP Chart sync: {{ last_sync.created_at_utc }} · {{ last_sync.group_count }} groups · {{ last_sync.ledger_count }} ledgers.

+ {% endif %} +
+ +
+ + + + +
+
+ {% if not node_online %} +
ERP Local Agent is not currently online for this branch.
+ {% elif not available_mappings %} +
Map this ERP client to a Tally company first from the Tally Accounting page.
+ {% endif %} +
+ +
+ {% for label,value in [ + ('Groups',summary.groups), + ('Ledgers',summary.ledgers), + ('Classified',summary.classified), + ('Review Required',summary.review), + ('Manual Overrides',summary.manual) + ] %} +
+
{{ label }}
+
{{ value }}
+
+ {% endfor %} +
+ +
+ +
+ + {% if view == 'ledgers' %} +
+
+ + + + + + + + + + +
+
+
+ +
+
+

Ledger Master

+

{{ total }} ledger(s). Manual role overrides survive future Chart refreshes.

+
+ +
+ + + + + + + + + + + + + {% for row in ledgers %} + {% set final_role = effective_role(row) %} + + + + + + + + + {% else %} + + {% endfor %} + +
LedgerParent / Root GroupBalancesGST / TaxAccounting RoleReview / Override
+
{{ row.name }}
+ {% if row.master_guid %}
{{ row.master_guid }}
{% endif %} + {% if row.party_gstin %}
{{ row.party_gstin }}
{% endif %} +
+
{{ row.parent_group_name or '—' }}
+
Root: {{ row.root_group_name or '—' }}
+
+
Opening: ₹{{ '%.2f'|format(row.opening_balance) }}
+
Closing: ₹{{ '%.2f'|format(row.closing_balance) }}
+
+
{{ row.tax_type or '—' }}
+
{{ row.gst_applicable or row.gst_registration_type or '—' }}
+ {% if row.hsn_code %}
HSN/SAC {{ row.hsn_code }}
{% endif %} +
+
{{ role_labels.get(final_role, final_role) }}
+ {% if row.manual_role_code %} + Manual + {% else %} +
{{ row.suggested_confidence }}% · Automatic
+
{{ row.suggestion_reason }}
+ {% endif %} +
+
+ + + + + + + + + + + + +
+
No Chart-of-Accounts ledgers have been synchronized for this selection yet.
+
+ + {% if pages > 1 %} +
+ Page {{ page }} of {{ pages }} +
+ {% if page > 1 %} + Previous + {% endif %} + {% if page < pages %} + Next + {% endif %} +
+
+ {% endif %} +
+ + {% else %} +
+
+

Tally Group Master

+

Every Group is resolved to its primary/root Tally Group. This hierarchy drives deterministic ledger-role classification.

+
+
+ + + + + + + + + + + + {% for row in groups %} + + + + + + + + {% else %} + + {% endfor %} + +
GroupParentRoot GroupDepthTally Flags
{{ row.name }}{{ row.parent_name or 'Primary' }}{{ row.root_group_name or row.name }}{{ row.depth }} + {% if row.reserved_name %}Reserved: {{ row.reserved_name }}{% endif %} + {% if row.is_revenue %}
Revenue: {{ row.is_revenue }}
{% endif %} + {% if row.is_deemed_positive %}
Deemed Positive: {{ row.is_deemed_positive }}
{% endif %} +
No Group master synchronized yet.
+
+
+ {% endif %} + {% endif %} +
+{% endblock %} diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html index 15fdba1..0bd8a04 100644 --- a/app/modules/accounting/templates/accounting/tally.html +++ b/app/modules/accounting/templates/accounting/tally.html @@ -13,6 +13,17 @@

Company mapping, master sync and transaction sync remain available. Accounting tools are now grouped into Purchases & Expenses and Sales & Income without changing existing routes or controls.

+
+
+
Accounting Foundation
+
Build the client Chart of Accounts from Tally Groups and Ledgers before transaction-level classification.
+
+ +
+ +
diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index f1fc288..26f6601 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -4,7 +4,7 @@ import io from pathlib import Path import zipfile -ERP_LOCAL_AGENT_VERSION = "1.15.1" +ERP_LOCAL_AGENT_VERSION = "1.16.0" ERP_LOCAL_AGENT_NAME = "ERP Local Agent" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index 479976a..ca33d77 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.15.1" +__version__ = "1.16.0" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py index 86a8822..7accf31 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py @@ -1077,6 +1077,61 @@ class LocalAccountingStore: ).fetchall() return [dict(row) for row in rows] + + def chart_of_accounts_snapshot(self, client_id: int, *, tally_guid: str) -> dict: + if not self.exists(client_id): + raise ValueError("Accounting storage is not initialized for this client.") + guid = str(tally_guid or "").strip() + if not guid: + raise ValueError("Tally company GUID is required.") + + mapping = self.get_active_mapping_by_guid(client_id, guid) + with self.connect(client_id) as db: + groups = [ + dict(row) + for row in db.execute( + """SELECT master_guid AS guid, name, parent, reserved_name, + is_revenue, is_deemed_positive, payload_json + FROM tally_groups + WHERE tally_guid=? + ORDER BY name COLLATE NOCASE""", + (guid,), + ).fetchall() + ] + ledgers = [ + dict(row) + for row in db.execute( + """SELECT master_guid AS guid, name, parent, + opening_balance, closing_balance, is_billwise_on, + tax_type, gst_applicable, gst_registration_type, + gst_type_of_supply, hsn_code, is_revenue, + is_deemed_positive, payload_json + FROM tally_ledgers + WHERE tally_guid=? + ORDER BY name COLLATE NOCASE""", + (guid,), + ).fetchall() + ] + + import json as _json + for row in ledgers: + try: + payload = _json.loads(row.get("payload_json") or "{}") + except Exception: + payload = {} + row["party_gstin"] = str(payload.get("party_gstin") or "") + row.pop("payload_json", None) + + for row in groups: + row.pop("payload_json", None) + + return { + "tally_guid": guid, + "company_name": str(mapping.get("company_name") or ""), + "groups": groups, + "ledgers": ledgers, + } + def historical_learning_evidence(self, client_id: int, *, tally_guid: str, date_from: str = "", date_to: str = "", voucher_scope: str = "purchase") -> dict: """Return neutral aggregated historical ledger facts from the local .act store. diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py index b82ab09..08c5fbb 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py @@ -35,6 +35,8 @@ class AgentCommandProcessor: result = self._unmap_company(payload) elif action == "accounting_sync_masters": result = self._sync_masters(payload) + elif action == "accounting_chart_of_accounts": + result = self._chart_of_accounts(payload) elif action == "accounting_sync_transactions": result = self._sync_transactions(payload) elif action == "accounting_historical_evidence": @@ -76,7 +78,7 @@ class AgentCommandProcessor: return { "name": "ERP Local Agent", "version": __version__, "tally_capability": True, "accounting_act_capability": True, - "tally_mapping_capability": True, "tally_master_sync_capability": True, + "tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "tally_transaction_sync_capability": True, "historical_learning_read_capability": True, "purchase_posting_preflight_capability": True, @@ -322,6 +324,21 @@ class AgentCommandProcessor: return evidence + + def _chart_of_accounts(self, payload: dict[str, Any]) -> dict[str, Any]: + # Read-only: refresh the existing master snapshot, then return Groups/Ledgers. + sync_result = self._sync_masters(payload) + client_id = int(payload.get("client_id")) + tally_guid = str(payload.get("tally_guid") or "").strip() + chart = self.store.chart_of_accounts_snapshot( + client_id, + tally_guid=tally_guid, + ) + return { + **sync_result, + "chart_of_accounts": chart, + } + def _resolve_open_company(self, payload: dict[str, Any]) -> tuple[dict[str, Any], str]: requested_guid = str(payload.get("tally_guid") or "").strip() if not requested_guid: diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py b/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py index 23f027f..8e661c7 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py @@ -114,7 +114,7 @@ class TallyLiveConnector: "collection": "ARRRAccountingLedgers", "type": "Ledger", "tag": "LEDGER", - "fetch": "Name,GUID,Parent,OpeningBalance,ClosingBalance,IsBillWiseOn,TaxType,GSTApplicable,GSTRegistrationType", + "fetch": "Name,GUID,Parent,OpeningBalance,ClosingBalance,IsBillWiseOn,TaxType,GSTApplicable,GSTRegistrationType,GSTTypeOfSupply,HSNCode,PartyGSTIN,GSTIN,GSTRegistrationNumber,IsRevenue,IsDeemedPositive,IsActive", }, "voucher_types": { "collection": "ARRRAccountingVoucherTypes", @@ -1095,6 +1095,10 @@ class TallyLiveConnector: "gst_registration_type": _child_text(element, "GSTREGISTRATIONTYPE"), "gst_type_of_supply": _child_text(element, "GSTTYPEOFSUPPLY"), "hsn_code": _first_text(element, ("HSNCODE", "GSTHSNCODE")), + "party_gstin": _first_text( + element, + ("PARTYGSTIN", "GSTIN", "GSTREGISTRATIONNUMBER", "GSTNO", "GSTNUMBER"), + ), "is_revenue": _child_text(element, "ISREVENUE"), "is_deemed_positive": _child_text(element, "ISDEEMEDPOSITIVE"), "is_billwise_on": _child_text(element, "ISBILLWISEON"), diff --git a/app/ui/app.py b/app/ui/app.py index 8c9b202..5a4d1cd 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -50,6 +50,7 @@ from app.modules.accounting.internal_model_ui import router as accounting_intern 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.accounting.sales_menu_ui import router as accounting_sales_menu_ui_router +from app.modules.accounting.chart_ui import router as accounting_chart_ui_router from app.modules.registrations.ui import router as registrations_ui_router from app.modules.credential_vault.ui import router as credential_vault_ui_router from app.modules.client_identity.ui import router as client_identity_ui_router @@ -86,6 +87,7 @@ def mount_ui(app: FastAPI) -> None: app.include_router(accounting_sales_income_ui_router) app.include_router(accounting_sales_learning_ui_router) app.include_router(accounting_sales_menu_ui_router) + app.include_router(accounting_chart_ui_router) app.include_router(work_tracker_ui_router) app.include_router(billing_ui_router) app.include_router(platform_billing_ui_router)