Files
arrr-erp/app/modules/accounting/creditors_aging_ui.py
T

202 lines
7.8 KiB
Python

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()