from __future__ import annotations from datetime import date from io import BytesIO import re from fastapi import APIRouter, Request from fastapi.responses import StreamingResponse from openpyxl import Workbook from openpyxl.styles import Font from app.core.db.common import CommonSessionLocal from app.core.security.csrf import get_or_create_csrf_token from app.core.templating import templates from app.modules.accounting.accounting_mirror_service import AccountingMirrorError, sundry_creditors_aging from app.modules.accounting.accounting_mirror_service import ( get_registered_mirror, list_registered_mirrors, sync_discovered_mirrors, ) from app.modules.accounting.agent_bridge import request_agent_command from app.modules.accounting.ui import ( _accounting_storage_payload, _analysis_accounting_storage_payload, _active_financial_year, _financial_year_bounds, _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/sundry-creditors", tags=["accounting-creditors-aging-ui"]) def _fy_options(selected_fy: str) -> list[str]: start, _ = _financial_year_bounds(selected_fy) years = list(range(start.year + 1, start.year - 7, -1)) return [f"{year}-{str(year + 1)[-2:]}" for year in years] def _resolve_fy(value: str, fallback: str) -> str: text = str(value or "").strip() if re.fullmatch(r"\d{4}-\d{2}", text): _financial_year_bounds(text) return text return fallback def _sync_client_mirror_registry(db, node, scope, user, client) -> None: if not node or not _node_online(node) or not client: return try: response = request_agent_command( node.node_code, "accounting_mirror_list", {"client_id": int(client.id)}, timeout_seconds=20, ) if response.get("ok"): sync_discovered_mirrors( db, tenant_id=scope.tenant_id, client_id=int(client.id), storage_node_id=int(node.id), discovered=(response.get("result") or {}).get("mirrors") or [], requested_by_user_id=int(user.id), ) except Exception: pass def _registered_follow_up_fys(db, tenant_id: int, client_id: int, selected_fy: str) -> list[str]: base_start, _ = _financial_year_bounds(selected_fy) values = [] for row in list_registered_mirrors(db, tenant_id, client_id): try: start, _ = _financial_year_bounds(row.financial_year) except Exception: continue if start > base_start: values.append(row.financial_year) return sorted(set(values), key=lambda fy: _financial_year_bounds(fy)[0]) def _load_analysis(request: Request, db, user, client_id: int, financial_year: str, follow_up_fy: str): client, clients, scope = _find_visible_client(db, request, user, client_id) if not client: return None, clients, scope, "Select a client visible to the active branch." node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) if not node or not _node_online(node): return None, clients, scope, "ERP Local Agent is offline for the active branch." _sync_client_mirror_registry(db, node, scope, user, client) base_registration = get_registered_mirror(db, scope.tenant_id, int(client.id), financial_year) if not base_registration: return None, clients, scope, f"No registered Accounting Mirror is available for FY {financial_year}. Create the mirror from Tally Accounting first." fy_start, fy_end = _financial_year_bounds(financial_year) follow_up_start = "" follow_up_end = "" follow_up_payload = None if follow_up_fy: follow_start, follow_end = _financial_year_bounds(follow_up_fy) if follow_start <= fy_end: return None, clients, scope, "Follow-up financial year must be after the analysis financial year." follow_up_registration = get_registered_mirror(db, scope.tenant_id, int(client.id), follow_up_fy) if not follow_up_registration: return None, clients, scope, f"No registered Accounting Mirror is available for follow-up FY {follow_up_fy}." follow_up_start = follow_start.isoformat() follow_up_end = follow_end.isoformat() follow_up_payload = _analysis_accounting_storage_payload(client, follow_up_fy, db=db, tenant_id=scope.tenant_id) payload = _analysis_accounting_storage_payload(client, financial_year, db=db, tenant_id=scope.tenant_id) try: result = sundry_creditors_aging( node_code=str(node.node_code), accounting_payload=payload, fy_start=fy_start.isoformat(), fy_end=fy_end.isoformat(), follow_up_accounting_payload=follow_up_payload, follow_up_start=follow_up_start, follow_up_end=follow_up_end, limit=50000, ) return result, clients, scope, "" except AccountingMirrorError as exc: return None, clients, scope, str(exc) except Exception as exc: return None, clients, scope, str(exc) @router.get("") def creditors_aging( request: Request, client_id: int | None = None, financial_year: str = "", follow_up_fy: str = "", analyze: int = 0, ): db = CommonSessionLocal() try: user, response = _require_partner(request, db, "accounting.tally.view") if response: return response clients, scope = _visible_clients(db, request, user) fallback_fy = _active_financial_year(request) try: selected_fy = _resolve_fy(financial_year, fallback_fy) except Exception: selected_fy = fallback_fy selected_follow_up = str(follow_up_fy or "").strip() selected_client = next((c for c in clients if client_id and int(c.id) == int(client_id)), None) registered_fys: list[str] = [] follow_up_options: list[str] = [] if selected_client: node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) _sync_client_mirror_registry(db, node, scope, user, selected_client) registered_fys = [row.financial_year for row in list_registered_mirrors(db, scope.tenant_id, int(selected_client.id))] follow_up_options = _registered_follow_up_fys(db, scope.tenant_id, int(selected_client.id), selected_fy) result = None error = "" if analyze and selected_client: result, clients, scope, error = _load_analysis( request, db, user, int(selected_client.id), selected_fy, selected_follow_up ) context = { "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), "clients": clients, "selected_client": selected_client, "selected_fy": selected_fy, "follow_up_fy": selected_follow_up, "fy_options": registered_fys or _fy_options(selected_fy), "follow_up_options": follow_up_options, "analysis": result, "error": error, } return templates.TemplateResponse( "modules/accounting/templates/accounting/sundry_creditors_aging.html", context ) finally: db.close() @router.get("/export.xlsx") def creditors_aging_export( request: Request, client_id: int, financial_year: str, follow_up_fy: str = "", ): db = CommonSessionLocal() try: user, response = _require_partner(request, db, "accounting.tally.view") if response: return response result, _, _, error = _load_analysis( request, db, user, int(client_id), financial_year, follow_up_fy ) if error or not result: from fastapi.responses import PlainTextResponse return PlainTextResponse(error or "No analysis data is available.", status_code=400) wb = Workbook() ws = wb.active ws.title = "Creditor Summary" headers = [ "Party Name", "<=180 Days", ">180 Days", "Closing Balance", ">180 Days Paid Subsequently", ">180 Days Still Unpaid", "Final Payment Date", ] ws.append(headers) for cell in ws[1]: cell.font = Font(bold=True) for row in result.get("parties") or []: ws.append([ row.get("party_name"), row.get("within_180"), row.get("over_180"), row.get("closing_balance"), row.get("over_180_paid_later"), row.get("over_180_still_unpaid"), row.get("final_payment_date") or "", ]) detail = wb.create_sheet("Bill FIFO Detail") detail_headers = [ "Party Name", "Source", "Voucher Date", "Voucher Type", "Voucher Number", "Reference", "Original Credit", "Outstanding at FY End", "Age Days", "Age Bucket", "Paid Subsequently", "Balance after Follow-up", "Final Payment Date", "Allocation Basis", ] detail.append(detail_headers) for cell in detail[1]: cell.font = Font(bold=True) for row in result.get("details") or []: detail.append([ row.get("party_name"), row.get("source"), row.get("voucher_date"), row.get("voucher_type"), row.get("voucher_number"), row.get("reference"), row.get("original_credit"), row.get("outstanding_at_fy_end"), row.get("age_days"), row.get("age_bucket"), row.get("paid_subsequently"), row.get("balance_after_follow_up"), row.get("final_payment_date") or "", row.get("allocation_basis"), ]) for sheet in (ws, detail): sheet.freeze_panes = "A2" for col in sheet.columns: width = min(36, max(12, max(len(str(c.value or "")) for c in col) + 2)) sheet.column_dimensions[col[0].column_letter].width = width out = BytesIO() wb.save(out) out.seek(0) filename = f"sundry_creditors_{financial_year.replace('-', '_')}.xlsx" return StreamingResponse( out, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) finally: db.close()