from __future__ import annotations import json from io import BytesIO from urllib.parse import urlencode from datetime import datetime from fastapi import APIRouter, Form, Request from fastapi.responses import RedirectResponse, StreamingResponse 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 from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment 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 def _summary_counts(raw): raw = raw or {} ledgers = raw.get("ledgers") or {} stock = raw.get("stock_items") or {} return { "ledgers": { "matched": int(ledgers.get("matched") or 0), "difference": int(ledgers.get("difference") or 0), "missing_in_current_year": int(ledgers.get("missing_in_current_year") or 0), "new_in_current_year": int(ledgers.get("new_in_current_year") or 0), }, "stock_items": { "matched": int(stock.get("matched") or 0), "quantity_difference": int(stock.get("quantity_difference") or 0), "value_difference": int(stock.get("value_difference") or 0), "quantity_and_value_difference": int(stock.get("quantity_and_value_difference") or 0), "unit_difference": int(stock.get("unit_difference") or 0), "missing_in_current_year": int(stock.get("missing_in_current_year") or 0), "new_in_current_year": int(stock.get("new_in_current_year") or 0), }, } def _page_rows(rows, page, per_page=50): per_page = max(20, min(200, int(per_page or 50))) total = len(rows) pages = max(1, (total + per_page - 1) // per_page) page = max(1, min(int(page or 1), pages)) start = (page - 1) * per_page return rows[start:start + per_page], total, page, pages @router.get("") def page( request: Request, client_id: int | None = None, run_id: int | None = None, tab: str = "summary", status: str = "", page: int = 1, per_page: int = 50, show_zero_new: int = 0, 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 = [] ledger_action_rows = [] stock_action_rows = [] summary = _summary_counts({}) detail_total = 0 detail_pages = 1 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 = _summary_counts(json.loads(selected_run.summary_json or "{}")) except Exception: summary = _summary_counts({}) if tab == "summary": ledger_action_rows = ledger_items( db, run_id=selected_run.id, status="difference", ) stock_action_rows = [] for action_status in ( "quantity_difference", "value_difference", "quantity_and_value_difference", "unit_difference", ): stock_action_rows.extend( stock_items( db, run_id=selected_run.id, status=action_status, ) ) ledger_action_rows = ledger_action_rows[:100] stock_action_rows = stock_action_rows[:100] elif tab == "stock": detail_status = status or "quantity_and_value_difference" rows = stock_items( db, run_id=selected_run.id, status=detail_status, ) if detail_status == "new_in_current_year" and not show_zero_new: rows = [ row for row in rows if abs(float(row.current_opening_qty or 0)) > 0.000001 or abs(float(row.current_opening_value or 0)) > 0.005 ] stocks, detail_total, page, detail_pages = _page_rows( rows, page, per_page, ) status = detail_status else: detail_status = status or "difference" rows = ledger_items( db, run_id=selected_run.id, status=detail_status, ) if detail_status == "new_in_current_year" and not show_zero_new: rows = [ row for row in rows if abs(float(row.current_opening_balance or 0)) > 0.005 ] ledgers, detail_total, page, detail_pages = _page_rows( rows, page, per_page, ) status = detail_status valid_tab = tab if tab in {"summary", "ledgers", "stock"} else "summary" 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, "ledger_action_rows": ledger_action_rows, "stock_action_rows": stock_action_rows, "summary": summary, "tab": valid_tab, "status_filter": status, "page": page, "pages": detail_pages, "per_page": per_page, "detail_total": detail_total, "show_zero_new": bool(show_zero_new), "message": message, "error": command_error, }, ) finally: db.close() @router.get("/download/{run_id}") def download_report(request: Request, run_id: int, client_id: int | None = None): """Download the complete opening-balance comparison as an Excel workbook.""" db = CommonSessionLocal() try: user, denied = _require_partner(request, db, "accounting.tally.view") if denied: return denied clients, scope = _visible_clients(db, request, user) run = db.get(AccountingOpeningBalanceRun, int(run_id)) if not run or run.tenant_id != scope.tenant_id: return _go(error="Opening balance comparison run was not found.") selected = next((row for row in clients if int(row.id) == int(run.client_id)), None) if not selected or (client_id and int(client_id) != int(run.client_id)): return _go(error="Opening balance comparison run is not visible.") ledger_rows = ledger_items(db, run_id=run.id) stock_rows = stock_items(db, run_id=run.id) wb = Workbook() ws = wb.active ws.title = "Balance Sheet Ledgers" headers = [ "Previous Ledger", "Previous Group", "PY Closing", "Current Ledger", "Current Group", "CY Opening", "Difference", "Status", "Match Method", "Confidence %", "Correction Status", "Correction Note", ] ws.append(headers) for row in ledger_rows: ws.append([ row.previous_name or "", row.previous_group or "", float(row.previous_closing_balance or 0), row.current_name or "", row.current_group or "", float(row.current_opening_balance or 0), float(row.difference or 0), row.match_status or "", row.match_method or "", int(row.confidence or 0), row.correction_status or "", row.correction_note or "", ]) sws = wb.create_sheet("Stock Items") stock_headers = [ "Previous Stock Item", "Previous Group", "HSN", "Unit", "PY Closing Qty", "PY Closing Value", "Current Stock Item", "Current Group", "Current HSN", "Current Unit", "CY Opening Qty", "CY Opening Value", "Qty Difference", "Value Difference", "Status", "Match Method", "Confidence %", "Correction Status", "Correction Note", ] sws.append(stock_headers) for row in stock_rows: sws.append([ row.previous_name or "", row.previous_group or "", row.previous_hsn or "", row.previous_unit or "", float(row.previous_closing_qty or 0), float(row.previous_closing_value or 0), row.current_name or "", row.current_group or "", row.current_hsn or "", row.current_unit or "", float(row.current_opening_qty or 0), float(row.current_opening_value or 0), float(row.quantity_difference or 0), float(row.value_difference or 0), row.match_status or "", row.match_method or "", int(row.confidence or 0), row.correction_status or "", row.correction_note or "", ]) meta = wb.create_sheet("Summary", 0) meta.append(["Opening Balance Review"]) meta.append(["Client", getattr(selected, "client_name", "") or getattr(selected, "name", "") or str(selected.id)]) meta.append(["Run ID", run.id]) meta.append(["Previous Tally Company", run.previous_company_name]) meta.append(["Current Tally Company", run.current_company_name]) meta.append(["Scope", "Balance Sheet ledgers and Stock Items only"]) meta.append(["Balance Sheet Ledger Rows", len(ledger_rows)]) meta.append(["Stock Item Rows", len(stock_rows)]) meta.append(["Generated", datetime.now().strftime("%Y-%m-%d %H:%M:%S")]) header_fill = PatternFill("solid", fgColor="DCE6F1") for sheet in (ws, sws): sheet.freeze_panes = "A2" sheet.auto_filter.ref = sheet.dimensions for cell in sheet[1]: cell.font = Font(bold=True) cell.fill = header_fill cell.alignment = Alignment(vertical="center", wrap_text=True) for col in sheet.columns: letter = col[0].column_letter width = min(38, max(10, max(len(str(c.value or "")) for c in col[:200]) + 2)) sheet.column_dimensions[letter].width = width meta["A1"].font = Font(bold=True, size=14) meta.column_dimensions["A"].width = 28 meta.column_dimensions["B"].width = 48 output = BytesIO() wb.save(output) output.seek(0) filename = f"opening_balance_review_run_{run.id}.xlsx" return StreamingResponse( output, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) 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="summary", 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="summary", 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()