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
++ 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. +
++ 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 %} +{{ total }} ledger(s). Manual role overrides survive future Chart refreshes.
+| Ledger | +Parent / Root Group | +Balances | +GST / Tax | +Accounting Role | +Review / 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. | |||||
Every Group is resolved to its primary/root Tally Group. This hierarchy drives deterministic ledger-role classification.
+| Group | +Parent | +Root Group | +Depth | +Tally 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. | ||||
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.