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.ui import ( _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 _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." 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_start = follow_start.isoformat() follow_up_end = follow_end.isoformat() follow_up_payload = _accounting_storage_payload(client, follow_up_fy) payload = _accounting_storage_payload(client, financial_year) 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) 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": _fy_options(selected_fy), "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()