Files
arrr-erp/app/modules/accounting/taxonomy_service.py
T
2026-08-22 12:37:20 +05:30

155 lines
12 KiB
Python

from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timezone
from sqlalchemy import select
from app.modules.accounting.taxonomy_models import AccountingNature
# Stable canonical codes are intentionally independent from Tally ledger names.
# Existing rows are never renamed/deleted automatically. ensure_standard_taxonomy()
# only inserts missing standard codes, so future releases remain non-destructive.
STANDARD_ACCOUNTING_NATURES = [
# code, name, parent, group, capital/revenue, posting, sort, description
("PURCHASE", "Purchases", None, "purchase", "revenue", False, 100, "Purchase-side classification root."),
("TRADING_PURCHASE", "Trading / Stock Purchase", "PURCHASE", "purchase", "revenue", True, 110, "Goods acquired principally for resale."),
("RAW_MATERIAL_PURCHASE", "Raw Material Purchase", "PURCHASE", "purchase", "revenue", True, 120, "Materials consumed in manufacturing or production."),
("CONSUMABLES", "Consumables", "PURCHASE", "purchase", "revenue", True, 130, "Consumables used in operations but not held primarily for resale."),
("PACKING_MATERIAL", "Packing Material", "PURCHASE", "purchase", "revenue", True, 140, "Packaging and packing materials."),
("STORES_SPARES", "Stores & Spares", "PURCHASE", "purchase", "revenue", True, 150, "Operational stores, spares and replacement parts."),
("OPERATING_EXPENSE", "Operating Expenses", None, "expense", "revenue", False, 200, "Revenue expenditure classification root."),
("TELEPHONE", "Telephone / Mobile Charges", "OPERATING_EXPENSE", "expense", "revenue", True, 210, "Telephone and mobile communication charges."),
("INTERNET", "Internet / Data Charges", "OPERATING_EXPENSE", "expense", "revenue", True, 220, "Broadband, leased line and data connectivity charges."),
("ELECTRICITY", "Electricity Charges", "OPERATING_EXPENSE", "expense", "revenue", True, 230, "Electricity and power utility charges not capitalized."),
("WATER_CHARGES", "Water Charges", "OPERATING_EXPENSE", "expense", "revenue", True, 240, "Water utility charges."),
("RENT", "Rent", "OPERATING_EXPENSE", "expense", "revenue", True, 250, "Premises/equipment rent treated as revenue expenditure."),
("INSURANCE", "Insurance", "OPERATING_EXPENSE", "expense", "revenue", True, 260, "General business insurance expenditure."),
("PROFESSIONAL_CHARGES", "Professional Charges", "OPERATING_EXPENSE", "expense", "revenue", True, 270, "Professional and consultancy fees."),
("LEGAL_FEES", "Legal Fees", "OPERATING_EXPENSE", "expense", "revenue", True, 280, "Legal and advocate fees."),
("AUDIT_FEES", "Audit Fees", "OPERATING_EXPENSE", "expense", "revenue", True, 290, "Statutory/internal/other audit fees."),
("ACCOUNTING_FEES", "Accounting / Bookkeeping Fees", "OPERATING_EXPENSE", "expense", "revenue", True, 300, "Accounting and bookkeeping service charges."),
("TRAVELLING", "Travelling Expenses", "OPERATING_EXPENSE", "expense", "revenue", True, 310, "Business travel expenditure excluding separately classified lodging."),
("HOTEL_ACCOMMODATION", "Hotel / Accommodation", "OPERATING_EXPENSE", "expense", "revenue", True, 320, "Business lodging and accommodation."),
("FREIGHT_CARRIAGE", "Freight / Carriage", "OPERATING_EXPENSE", "expense", "revenue", True, 330, "Freight, carriage and transport of goods where expensed."),
("COURIER_POSTAGE", "Courier / Postage", "OPERATING_EXPENSE", "expense", "revenue", True, 340, "Courier, postal and delivery charges."),
("PRINTING_STATIONERY", "Printing & Stationery", "OPERATING_EXPENSE", "expense", "revenue", True, 350, "Printing, stationery and office consumables."),
("OFFICE_EXPENSES", "Office Expenses", "OPERATING_EXPENSE", "expense", "revenue", True, 360, "General office operating expenses."),
("STAFF_WELFARE", "Staff Welfare", "OPERATING_EXPENSE", "expense", "revenue", True, 370, "Employee welfare expenditure."),
("SOFTWARE_SUBSCRIPTION", "Software / Subscription Charges", "OPERATING_EXPENSE", "expense", "revenue", True, 380, "Software subscriptions, SaaS and similar recurring services."),
("CLOUD_HOSTING", "Cloud / Hosting Charges", "OPERATING_EXPENSE", "expense", "revenue", True, 390, "Cloud infrastructure, hosting and domain-related recurring charges."),
("SECURITY_HOUSEKEEPING", "Security / Housekeeping", "OPERATING_EXPENSE", "expense", "revenue", True, 400, "Security, housekeeping and facility support services."),
("ADVERTISEMENT_MARKETING", "Advertisement / Marketing", "OPERATING_EXPENSE", "expense", "revenue", True, 410, "Advertising, promotion and marketing expenditure."),
("COMMISSION_BROKERAGE", "Commission / Brokerage", "OPERATING_EXPENSE", "expense", "revenue", True, 420, "Commission, brokerage and intermediary charges."),
("BANK_CHARGES", "Bank Charges", "OPERATING_EXPENSE", "expense", "revenue", True, 430, "Banking and transaction charges."),
("REPAIRS_MAINTENANCE", "Repairs & Maintenance", None, "repairs", "revenue", False, 500, "Repair and maintenance classification root."),
("GENERAL_REPAIRS", "General Repairs & Maintenance", "REPAIRS_MAINTENANCE", "repairs", "revenue", True, 510, "General repairs not fitting a more specific class."),
("BUILDING_MAINTENANCE", "Building Repairs & Maintenance", "REPAIRS_MAINTENANCE", "repairs", "revenue", True, 520, "Revenue repairs and maintenance of buildings/premises."),
("MACHINERY_MAINTENANCE", "Plant & Machinery Repairs", "REPAIRS_MAINTENANCE", "repairs", "revenue", True, 530, "Repairs and maintenance of plant and machinery."),
("VEHICLE_MAINTENANCE", "Vehicle Repairs & Maintenance", "REPAIRS_MAINTENANCE", "repairs", "revenue", True, 540, "Vehicle service, spares, tyres and repair expenditure."),
("COMPUTER_MAINTENANCE", "Computer Repairs & Maintenance", "REPAIRS_MAINTENANCE", "repairs", "revenue", True, 550, "Computer and IT hardware repair expenditure."),
("ELECTRICAL_MAINTENANCE", "Electrical Repairs & Maintenance", "REPAIRS_MAINTENANCE", "repairs", "revenue", True, 560, "Electrical repair and maintenance expenditure."),
("CAPITAL_ASSET", "Capital Assets", None, "capital", "capital", False, 600, "Capital expenditure classification root."),
("LAND", "Land", "CAPITAL_ASSET", "capital", "capital", True, 610, "Land and land acquisition cost."),
("BUILDING", "Building", "CAPITAL_ASSET", "capital", "capital", True, 620, "Building capital expenditure."),
("PLANT_MACHINERY", "Plant & Machinery", "CAPITAL_ASSET", "capital", "capital", True, 630, "Plant and machinery capital expenditure."),
("FURNITURE_FIXTURES", "Furniture & Fixtures", "CAPITAL_ASSET", "capital", "capital", True, 640, "Furniture and fixtures capital expenditure."),
("COMPUTERS", "Computers", "CAPITAL_ASSET", "capital", "capital", True, 650, "Computers and qualifying computer hardware capital expenditure."),
("OFFICE_EQUIPMENT", "Office Equipment", "CAPITAL_ASSET", "capital", "capital", True, 660, "Office equipment capital expenditure."),
("MOTOR_VEHICLES", "Motor Vehicles", "CAPITAL_ASSET", "capital", "capital", True, 670, "Motor vehicle capital expenditure."),
("INTANGIBLE_ASSET", "Intangible Assets", "CAPITAL_ASSET", "capital", "capital", True, 680, "Qualifying intangible asset expenditure."),
("LEASEHOLD_IMPROVEMENTS", "Leasehold Improvements", "CAPITAL_ASSET", "capital", "capital", True, 690, "Capitalized leasehold improvement expenditure."),
("CAPITAL_WIP", "Capital Work-in-Progress", "CAPITAL_ASSET", "capital", "capital", True, 700, "Expenditure accumulated as capital work-in-progress."),
("TAX_STATUTORY", "Taxes & Statutory Charges", None, "statutory", "revenue", False, 800, "Taxes and statutory charges classification root."),
("GST_EXPENSE", "GST / Indirect Tax Expense", "TAX_STATUTORY", "statutory", "revenue", True, 810, "Indirect taxes treated as expenditure rather than recoverable credit."),
("MUNICIPAL_TAX", "Municipal / Property Tax", "TAX_STATUTORY", "statutory", "revenue", True, 820, "Municipal and property taxes."),
("LICENSE_FEES", "Licence / Registration Fees", "TAX_STATUTORY", "statutory", "revenue", True, 830, "Business licence and registration charges normally expensed."),
("FINANCE_COST", "Finance Costs", None, "finance", "revenue", False, 900, "Finance cost classification root."),
("INTEREST_EXPENSE", "Interest Expense", "FINANCE_COST", "finance", "revenue", True, 910, "Interest and borrowing cost treated as revenue expenditure."),
("LOAN_PROCESSING_CHARGES", "Loan Processing / Finance Charges", "FINANCE_COST", "finance", "revenue", True, 920, "Loan processing and other finance charges normally expensed."),
("REVIEW_REQUIRED", "Unclassified / Review Required", None, "review", "review", True, 9990, "Fallback when available evidence is insufficient for a reliable classification."),
]
def ensure_standard_taxonomy(db, tenant_id: int, actor_user_id: int | None = None) -> int:
existing = set(
db.execute(select(AccountingNature.code).where(AccountingNature.tenant_id == tenant_id)).scalars().all()
)
inserted = 0
for code, name, parent_code, group, cap_rev, posting, sort_order, description in STANDARD_ACCOUNTING_NATURES:
if code in existing:
continue
db.add(
AccountingNature(
tenant_id=tenant_id,
code=code,
name=name,
parent_code=parent_code,
classification_group=group,
capital_revenue=cap_rev,
description=description,
sort_order=sort_order,
is_posting_nature=posting,
is_active=True,
is_system_defined=True,
created_by_user_id=actor_user_id,
updated_by_user_id=actor_user_id,
)
)
inserted += 1
if inserted:
db.commit()
return inserted
def list_taxonomy(db, tenant_id: int, *, status: str = "all", group: str = "") -> list[AccountingNature]:
stmt = select(AccountingNature).where(AccountingNature.tenant_id == tenant_id)
if status == "active":
stmt = stmt.where(AccountingNature.is_active.is_(True))
elif status == "inactive":
stmt = stmt.where(AccountingNature.is_active.is_(False))
if group:
stmt = stmt.where(AccountingNature.classification_group == group)
return db.execute(stmt.order_by(AccountingNature.sort_order.asc(), AccountingNature.name.asc())).scalars().all()
def grouped_taxonomy(rows: list[AccountingNature]) -> list[dict]:
children: dict[str | None, list[AccountingNature]] = defaultdict(list)
by_code = {row.code: row for row in rows}
for row in rows:
children[row.parent_code].append(row)
result = []
for root in children.get(None, []):
result.append({"root": root, "children": children.get(root.code, [])})
# If filters excluded roots, retain orphaned matching rows under their known parent label where possible.
seen = {item["root"].code for item in result}
for row in rows:
if row.parent_code and row.parent_code not in seen and row.parent_code not in by_code:
result.append({"root": None, "parent_code": row.parent_code, "children": [row]})
return result
def set_nature_active(db, *, tenant_id: int, nature_id: int, is_active: bool, actor_user_id: int | None) -> AccountingNature | None:
row = db.execute(
select(AccountingNature).where(AccountingNature.id == nature_id, AccountingNature.tenant_id == tenant_id)
).scalar_one_or_none()
if row is None:
return None
if not row.is_posting_nature:
raise ValueError("Classification group headings cannot be disabled individually.")
row.is_active = bool(is_active)
row.updated_by_user_id = actor_user_id
row.updated_at_utc = datetime.now(timezone.utc)
db.add(row)
db.commit()
db.refresh(row)
return row