from __future__ import annotations import json from urllib.parse import urlencode from fastapi import APIRouter, Form, Request from fastapi.responses import RedirectResponse from app.core.db.common import CommonSessionLocal from app.core.security.csrf import get_or_create_csrf_token, validate_csrf from app.core.templating import templates from app.modules.accounting.agent_bridge import request_agent_command from app.modules.accounting.opening_balance_models import AccountingOpeningBalanceRun from app.modules.accounting.opening_balance_service import ( apply_correction_results, correction_payload, create_comparison_run, ledger_items, list_runs, save_master_mapping, stock_items, ) from app.modules.accounting.ui import ( _accounting_storage_payload, _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/opening-balances", tags=["accounting-opening-balance-ui"], ) def _go(client_id=0, message="", error="", **extra): q = {} if client_id: q["client_id"] = client_id if message: q["message"] = message[:500] if error: q["error"] = error[:500] for key, value in extra.items(): if value not in (None, ""): q[key] = value return RedirectResponse( "/tools/accounting/opening-balances" + ("?" + urlencode(q) if q else ""), status_code=303, ) def _company_rows(tally): rows = [] seen = set() for row in (tally or {}).get("companies") or []: if isinstance(row, str): item = {"name": row, "guid": ""} else: item = { "name": str(row.get("name") or "").strip(), "guid": str(row.get("guid") or "").strip(), } if item["name"] and item["name"].casefold() not in seen: seen.add(item["name"].casefold()) rows.append(item) return rows @router.get("") def page( request: Request, client_id: int | None = None, run_id: int | None = None, tab: str = "ledgers", status: str = "", message: str = "", error: str = "", ): db = CommonSessionLocal() try: user, denied = _require_partner(request, db, "accounting.tally.view") if denied: return denied clients, scope = _visible_clients(db, request, user) selected = next( (row for row in clients if client_id and int(row.id) == int(client_id)), None, ) node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) online = bool(node and _node_online(node)) tally = None companies = [] command_error = error or "" if selected and node and online: try: response = request_agent_command( node.node_code, "phase6_status", _accounting_storage_payload(selected), timeout_seconds=20, ) if response.get("ok"): tally = (response.get("result") or {}).get("tally") companies = _company_rows(tally) else: command_error = str(response.get("error") or "Local Agent status failed.") except Exception as exc: command_error = str(exc) runs = [] selected_run = None ledgers = [] stocks = [] summary = {} if selected: runs = list_runs( db, tenant_id=scope.tenant_id, client_id=selected.id, limit=30, ) if run_id: selected_run = next((row for row in runs if int(row.id) == int(run_id)), None) elif runs: selected_run = runs[0] if selected_run: try: summary = json.loads(selected_run.summary_json or "{}") except Exception: summary = {} if tab == "stock": stocks = stock_items(db, run_id=selected_run.id, status=status) else: ledgers = ledger_items(db, run_id=selected_run.id, status=status) return templates.TemplateResponse( "modules/accounting/templates/accounting/opening_balances.html", { "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), "title": "Opening Balance Review", "clients": clients, "selected_client": selected, "node_online": online, "tally": tally, "companies": companies, "runs": runs, "selected_run": selected_run, "ledgers": ledgers, "stocks": stocks, "summary": summary, "tab": tab if tab in {"ledgers", "stock"} else "ledgers", "status_filter": status, "message": message, "error": command_error, }, ) finally: db.close() @router.post("/compare") def compare( request: Request, client_id: int = Form(...), previous_company_name: str = Form(...), current_company_name: str = Form(...), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) db = CommonSessionLocal() try: user, denied = _require_partner(request, db, "accounting.tally.sync_masters") if denied: return denied client, _clients, scope = _find_visible_client(db, request, user, client_id) if not client: return _go(error="Client is not visible.") if previous_company_name.strip().casefold() == current_company_name.strip().casefold(): raise ValueError( "Previous-year and current-year companies must be different in Phase 21A. " "This protects against using the current company's latest closing balance as the prior-year closing." ) node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) if not node or not _node_online(node): raise ValueError("ERP Local Agent is offline for the active branch.") response = request_agent_command( node.node_code, "accounting_opening_balance_snapshot", { **_accounting_storage_payload(client), "previous_company_name": previous_company_name, "current_company_name": current_company_name, }, timeout_seconds=180, ) if not response.get("ok"): raise RuntimeError(str(response.get("error") or "Opening balance snapshot failed.")) result = response.get("result") or {} run = create_comparison_run( db, tenant_id=scope.tenant_id, client_id=client.id, previous_company=result.get("previous_company") or {}, current_company=result.get("current_company") or {}, previous_masters=result.get("previous_masters") or {}, current_masters=result.get("current_masters") or {}, user_id=user.id, ) return _go( client.id, run_id=run.id, tab="ledgers", message=( f"Opening balance comparison #{run.id} completed: " f"{run.ledger_count} ledger row(s), {run.stock_item_count} stock row(s)." ), ) except Exception as exc: db.rollback() return _go(client_id, error=str(exc)) finally: db.close() @router.post("/mapping") def mapping( request: Request, client_id: int = Form(...), run_id: int = Form(...), master_type: str = Form(...), previous_name: str = Form(...), current_name: str = Form(...), note: str = Form(""), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) db = CommonSessionLocal() try: user, denied = _require_partner(request, db, "accounting.learning.manage") if denied: return denied client, _clients, scope = _find_visible_client(db, request, user, client_id) if not client: return _go(error="Client is not visible.") run = db.get(AccountingOpeningBalanceRun, int(run_id)) if not run or run.client_id != client.id or run.tenant_id != scope.tenant_id: raise ValueError("Opening balance comparison run was not found.") save_master_mapping( db, tenant_id=scope.tenant_id, client_id=client.id, master_type=master_type, previous_company_guid=run.previous_company_guid, previous_name=previous_name, current_company_guid=run.current_company_guid, current_name=current_name, user_id=user.id, note=note, ) return _go( client.id, run_id=run.id, tab=("stock" if master_type == "stock_item" else "ledgers"), message="Carry-forward master mapping saved. Re-run the comparison to apply it.", ) except Exception as exc: db.rollback() return _go(client_id, run_id=run_id, error=str(exc)) finally: db.close() @router.post("/apply") async def apply_corrections( request: Request, client_id: int = Form(...), run_id: int = Form(...), confirm_text: str = Form(...), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) form = await request.form() ledger_ids = [int(value) for value in form.getlist("ledger_ids") if str(value).isdigit()] stock_ids = [int(value) for value in form.getlist("stock_ids") if str(value).isdigit()] db = CommonSessionLocal() try: user, denied = _require_partner(request, db, "accounting.tally.sync_masters") if denied: return denied if confirm_text.strip().upper() != "APPLY OPENING BALANCES": raise ValueError('Type "APPLY OPENING BALANCES" to confirm direct Tally master changes.') client, _clients, scope = _find_visible_client(db, request, user, client_id) if not client: return _go(error="Client is not visible.") run = db.get(AccountingOpeningBalanceRun, int(run_id)) if not run or run.client_id != client.id or run.tenant_id != scope.tenant_id: raise ValueError("Opening balance comparison run was not found.") ledger_rows, stock_rows = correction_payload( db, run=run, ledger_ids=ledger_ids, stock_ids=stock_ids, ) if not ledger_rows and not stock_rows: raise ValueError( "No safe correction rows were selected. Missing masters, unit differences, " "already-matched rows and ambiguous rows cannot be changed automatically." ) node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) if not node or not _node_online(node): raise ValueError("ERP Local Agent is offline for the active branch.") response = request_agent_command( node.node_code, "accounting_opening_balance_apply", { **_accounting_storage_payload(client), "current_company_name": run.current_company_name, "current_company_guid": run.current_company_guid, "ledgers": ledger_rows, "stock_items": stock_rows, }, timeout_seconds=240, ) if not response.get("ok"): raise RuntimeError(str(response.get("error") or "Tally opening-balance correction failed.")) result = response.get("result") or {} apply_correction_results( db, run_id=run.id, result=result, user_id=user.id, ) verified = sum(1 for row in (result.get("ledgers") or []) if row.get("verified")) verified += sum(1 for row in (result.get("stock_items") or []) if row.get("verified")) failed = sum(1 for row in (result.get("ledgers") or []) if not row.get("verified")) failed += sum(1 for row in (result.get("stock_items") or []) if not row.get("verified")) return _go( client.id, run_id=run.id, tab="ledgers", message=f"Tally correction completed: {verified} verified, {failed} failed/blocked.", ) except Exception as exc: db.rollback() return _go(client_id, run_id=run_id, error=str(exc)) finally: db.close()