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 review, including the web summary action reports.""" 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.") # Complete detailed reports. ledger_rows = ledger_items(db, run_id=run.id) stock_rows = stock_items(db, run_id=run.id) # These are the same action-required populations shown on the web Summary tab. ledger_action_rows = ledger_items(db, run_id=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=run.id, status=action_status)) try: summary_counts = _summary_counts(json.loads(run.summary_json or "{}")) except Exception: summary_counts = _summary_counts({}) wb = Workbook() meta = wb.active meta.title = "Summary" 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(["Ledger Differences — Action Required", len(ledger_action_rows)]) meta.append(["Stock Differences — Action Required", len(stock_action_rows)]) meta.append([]) meta.append(["Ledger Status", "Count"]) for label, key in ( ("Matched", "matched"), ("Difference", "difference"), ("Missing in Current Year", "missing_in_current_year"), ("New in Current Year", "new_in_current_year"), ): meta.append([label, summary_counts["ledgers"].get(key, 0)]) meta.append([]) meta.append(["Stock Item Status", "Count"]) for label, key in ( ("Matched", "matched"), ("Quantity Difference", "quantity_difference"), ("Value Difference", "value_difference"), ("Quantity & Value Difference", "quantity_and_value_difference"), ("Unit Difference", "unit_difference"), ("Missing in Current Year", "missing_in_current_year"), ("New in Current Year", "new_in_current_year"), ): meta.append([label, summary_counts["stock_items"].get(key, 0)]) meta.append([]) meta.append(["Generated", datetime.now().strftime("%Y-%m-%d %H:%M:%S")]) # Mirror the web Summary -> Ledger Differences — Action Required table. law = wb.create_sheet("Ledger Action Summary") ledger_action_headers = [ "Apply", "Previous Ledger", "Previous Group", "PY Closing", "Current Ledger", "Current Group", "CY Opening", "Difference", "Match", "Match Method", "Confidence %", "Correction Status", "Correction Note", ] law.append(ledger_action_headers) for row in ledger_action_rows: law.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 "", ]) # Mirror the web Summary -> Stock Differences — Action Required table. saw = wb.create_sheet("Stock Action Summary") stock_action_headers = [ "Apply", "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", ] saw.append(stock_action_headers) for row in stock_action_rows: saw.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 "", ]) ws = wb.create_sheet("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 "", ]) header_fill = PatternFill("solid", fgColor="DCE6F1") action_fill = PatternFill("solid", fgColor="FCE4D6") for sheet in (law, saw, ws, sws): sheet.freeze_panes = "A2" sheet.auto_filter.ref = sheet.dimensions for cell in sheet[1]: cell.font = Font(bold=True) cell.fill = action_fill if sheet in (law, saw) else header_fill cell.alignment = Alignment(vertical="center", wrap_text=True) for col in sheet.columns: letter = col[0].column_letter width = min(42, 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 = 38 meta.column_dimensions["B"].width = 48 for row_no in (12, 18): for cell in meta[row_no]: cell.font = Font(bold=True) cell.fill = header_fill 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()