260 lines
10 KiB
Python
260 lines
10 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.accounting_mirror_registry 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,
|
|
_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 = _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)
|
|
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()
|