Add Phase 16 Chart of Accounts and ledger group intelligence
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -0,0 +1,270 @@
|
||||
{% 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-brand-600">Accounting Foundation · Phase 16</p>
|
||||
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Chart of Accounts · Groups & Ledgers</h1>
|
||||
<p class="mt-1 max-w-5xl text-sm text-slate-500">
|
||||
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.
|
||||
</p>
|
||||
</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 Tally Accounting</a>
|
||||
</div>
|
||||
|
||||
{% if message %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">{{ message }}</div>{% endif %}
|
||||
{% 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 lg:grid-cols-[1fr_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>
|
||||
|
||||
<label class="text-sm font-semibold">Mapped Tally Company
|
||||
<select name="tally_guid" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2">
|
||||
<option value="">All synchronized companies</option>
|
||||
{% for m in available_mappings %}
|
||||
<option value="{{ m.guid }}" {% if selected_tally_guid==m.guid %}selected{% endif %}>
|
||||
{{ m.name }}{% if m.currently_loaded %} · Open now{% endif %}
|
||||
</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 Chart</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if selected_client %}
|
||||
<section class="rounded-2xl border border-blue-200 bg-blue-50/40 p-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-semibold text-slate-900">Refresh from Tally Group & Ledger Master</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">
|
||||
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.
|
||||
</p>
|
||||
{% if last_sync %}
|
||||
<p class="mt-2 text-xs text-slate-500">Last ERP Chart sync: {{ last_sync.created_at_utc }} · {{ last_sync.group_count }} groups · {{ last_sync.ledger_count }} ledgers.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="post" action="/tools/accounting/chart-of-accounts/sync" class="flex flex-wrap items-end gap-2">
|
||||
<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">Currently open mapped company
|
||||
<select name="tally_guid" required class="mt-1 min-w-72 rounded-lg border border-slate-300 bg-white px-3 py-2">
|
||||
<option value="">Select company</option>
|
||||
{% for m in available_mappings %}
|
||||
{% if m.currently_loaded %}
|
||||
<option value="{{ m.guid }}" {% if selected_tally_guid==m.guid %}selected{% endif %}>{{ m.name }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button {% if not node_online %}disabled{% endif %} class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50">Refresh Chart of Accounts</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if not node_online %}
|
||||
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">ERP Local Agent is not currently online for this branch.</div>
|
||||
{% elif not available_mappings %}
|
||||
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">Map this ERP client to a Tally company first from the Tally Accounting page.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{% for label,value in [
|
||||
('Groups',summary.groups),
|
||||
('Ledgers',summary.ledgers),
|
||||
('Classified',summary.classified),
|
||||
('Review Required',summary.review),
|
||||
('Manual Overrides',summary.manual)
|
||||
] %}
|
||||
<div class="rounded-2xl bg-white p-4 shadow-soft">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{ label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ value }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-4 shadow-soft">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="?client_id={{ selected_client.id }}{% if selected_tally_guid %}&tally_guid={{ selected_tally_guid }}{% endif %}&view=ledgers"
|
||||
class="rounded-lg px-4 py-2 text-sm font-semibold {% if view=='ledgers' %}bg-slate-900 text-white{% else %}border border-slate-300 bg-white{% endif %}">Ledger Master</a>
|
||||
<a href="?client_id={{ selected_client.id }}{% if selected_tally_guid %}&tally_guid={{ selected_tally_guid }}{% endif %}&view=groups"
|
||||
class="rounded-lg px-4 py-2 text-sm font-semibold {% if view=='groups' %}bg-slate-900 text-white{% else %}border border-slate-300 bg-white{% endif %}">Group Master</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if view == 'ledgers' %}
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<form method="get" class="grid gap-3 lg:grid-cols-5">
|
||||
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
|
||||
<input type="hidden" name="view" value="ledgers">
|
||||
|
||||
<label class="text-xs font-semibold lg:col-span-2">Search Ledger / Group / GSTIN
|
||||
<input name="q" value="{{ q }}" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2">
|
||||
</label>
|
||||
|
||||
<label class="text-xs font-semibold">Accounting Role
|
||||
<select name="role" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
|
||||
<option value="">All roles</option>
|
||||
{% for code,label in role_options %}
|
||||
<option value="{{ code }}" {% if role_filter==code %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="text-xs font-semibold">Root Group
|
||||
<select name="root_group" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
|
||||
<option value="">All root groups</option>
|
||||
{% for root in root_groups %}
|
||||
<option value="{{ root }}" {% if root_group_filter==root %}selected{% endif %}>{{ root }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="flex items-end"><button class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Filter</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4">
|
||||
<h2 class="font-semibold text-slate-900">Ledger Master</h2>
|
||||
<p class="text-sm text-slate-500">{{ total }} ledger(s). Manual role overrides survive future Chart refreshes.</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 tracking-wide text-slate-500">
|
||||
<th class="px-3 py-3">Ledger</th>
|
||||
<th class="px-3 py-3">Parent / Root Group</th>
|
||||
<th class="px-3 py-3">Balances</th>
|
||||
<th class="px-3 py-3">GST / Tax</th>
|
||||
<th class="px-3 py-3">Accounting Role</th>
|
||||
<th class="px-3 py-3">Review / Override</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in ledgers %}
|
||||
{% set final_role = effective_role(row) %}
|
||||
<tr class="border-b border-slate-100 align-top">
|
||||
<td class="px-3 py-3">
|
||||
<div class="font-semibold text-slate-900">{{ row.name }}</div>
|
||||
{% if row.master_guid %}<div class="mt-1 max-w-56 truncate font-mono text-[11px] text-slate-400">{{ row.master_guid }}</div>{% endif %}
|
||||
{% if row.party_gstin %}<div class="mt-1 font-mono text-xs text-slate-500">{{ row.party_gstin }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<div>{{ row.parent_group_name or '—' }}</div>
|
||||
<div class="mt-1 text-xs font-semibold text-slate-500">Root: {{ row.root_group_name or '—' }}</div>
|
||||
</td>
|
||||
<td class="px-3 py-3 whitespace-nowrap">
|
||||
<div>Opening: ₹{{ '%.2f'|format(row.opening_balance) }}</div>
|
||||
<div>Closing: ₹{{ '%.2f'|format(row.closing_balance) }}</div>
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<div>{{ row.tax_type or '—' }}</div>
|
||||
<div class="text-xs text-slate-500">{{ row.gst_applicable or row.gst_registration_type or '—' }}</div>
|
||||
{% if row.hsn_code %}<div class="text-xs text-slate-500">HSN/SAC {{ row.hsn_code }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<div class="font-semibold">{{ role_labels.get(final_role, final_role) }}</div>
|
||||
{% if row.manual_role_code %}
|
||||
<span class="mt-1 inline-block rounded-full bg-emerald-50 px-2 py-1 text-xs font-semibold text-emerald-700">Manual</span>
|
||||
{% else %}
|
||||
<div class="mt-1 text-xs text-slate-500">{{ row.suggested_confidence }}% · Automatic</div>
|
||||
<div class="mt-1 max-w-72 text-xs text-slate-500">{{ row.suggestion_reason }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<form method="post" action="/tools/accounting/chart-of-accounts/ledger/{{ row.id }}/role" class="min-w-64 space-y-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
|
||||
<input type="hidden" name="q" value="{{ q }}">
|
||||
<input type="hidden" name="role_filter" value="{{ role_filter }}">
|
||||
<input type="hidden" name="root_group" value="{{ root_group_filter }}">
|
||||
<input type="hidden" name="page" value="{{ page }}">
|
||||
<input type="hidden" name="per_page" value="{{ per_page }}">
|
||||
|
||||
<select name="role_code" class="w-full rounded-lg border border-slate-300 bg-white px-2 py-2 text-xs">
|
||||
<option value="">Use automatic suggestion</option>
|
||||
{% for code,label in role_options %}
|
||||
<option value="{{ code }}" {% if row.manual_role_code==code %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input name="note" value="{{ row.manual_note or '' }}" placeholder="Optional reviewer note" class="w-full rounded-lg border border-slate-300 px-2 py-2 text-xs">
|
||||
<button class="w-full rounded-lg border border-slate-300 px-3 py-2 text-xs font-semibold">Save Role</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="px-4 py-12 text-center text-slate-500">No Chart-of-Accounts ledgers have been synchronized for this selection yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if pages > 1 %}
|
||||
<div class="flex items-center justify-between border-t border-slate-200 p-4 text-sm">
|
||||
<span>Page {{ page }} of {{ pages }}</span>
|
||||
<div class="flex gap-2">
|
||||
{% if page > 1 %}
|
||||
<a class="rounded-lg border border-slate-300 px-3 py-1" href="?client_id={{ selected_client.id }}&tally_guid={{ selected_tally_guid }}&view=ledgers&q={{ q }}&role={{ role_filter }}&root_group={{ root_group_filter }}&page={{ page-1 }}&per_page={{ per_page }}">Previous</a>
|
||||
{% endif %}
|
||||
{% if page < pages %}
|
||||
<a class="rounded-lg border border-slate-300 px-3 py-1" href="?client_id={{ selected_client.id }}&tally_guid={{ selected_tally_guid }}&view=ledgers&q={{ q }}&role={{ role_filter }}&root_group={{ root_group_filter }}&page={{ page+1 }}&per_page={{ per_page }}">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% else %}
|
||||
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4">
|
||||
<h2 class="font-semibold text-slate-900">Tally Group Master</h2>
|
||||
<p class="text-sm text-slate-500">Every Group is resolved to its primary/root Tally Group. This hierarchy drives deterministic ledger-role classification.</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 tracking-wide text-slate-500">
|
||||
<th class="px-3 py-3">Group</th>
|
||||
<th class="px-3 py-3">Parent</th>
|
||||
<th class="px-3 py-3">Root Group</th>
|
||||
<th class="px-3 py-3">Depth</th>
|
||||
<th class="px-3 py-3">Tally Flags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in groups %}
|
||||
<tr class="border-b border-slate-100">
|
||||
<td class="px-3 py-3 font-semibold">{{ row.name }}</td>
|
||||
<td class="px-3 py-3">{{ row.parent_name or 'Primary' }}</td>
|
||||
<td class="px-3 py-3">{{ row.root_group_name or row.name }}</td>
|
||||
<td class="px-3 py-3">{{ row.depth }}</td>
|
||||
<td class="px-3 py-3 text-xs text-slate-500">
|
||||
{% if row.reserved_name %}Reserved: {{ row.reserved_name }}{% endif %}
|
||||
{% if row.is_revenue %}<div>Revenue: {{ row.is_revenue }}</div>{% endif %}
|
||||
{% if row.is_deemed_positive %}<div>Deemed Positive: {{ row.is_deemed_positive }}</div>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="px-4 py-12 text-center text-slate-500">No Group master synchronized yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -13,6 +13,17 @@
|
||||
<p class="mt-1 text-sm text-slate-500">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.</p>
|
||||
</div>
|
||||
<div class="w-full space-y-3 xl:max-w-5xl">
|
||||
<div class="rounded-2xl border border-blue-200 bg-blue-50/40 p-4">
|
||||
<div class="mb-3">
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.14em] text-blue-700">Accounting Foundation</div>
|
||||
<div class="text-xs text-slate-500">Build the client Chart of Accounts from Tally Groups and Ledgers before transaction-level classification.</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/tools/accounting/chart-of-accounts{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl bg-blue-700 px-3 py-2 text-sm font-semibold text-white">Chart of Accounts - Groups & Ledgers</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = "1.15.1"
|
||||
__version__ = "1.16.0"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user