from __future__ import annotations import json import re from io import BytesIO from urllib.parse import urlencode from datetime import date, 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, _analysis_accounting_storage_payload, _financial_year_for_date, _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 def _previous_financial_year(financial_year: str) -> str: """Return the immediately preceding Indian financial year.""" value = str(financial_year or "").strip() match = re.fullmatch(r"(\d{4})-(\d{2})", value) if not match: raise ValueError(f"Invalid financial year: {value}") start_year = int(match.group(1)) previous_start = start_year - 1 return f"{previous_start}-{str(start_year)[-2:]}" def _financial_year_options(reference_fy: str, years_back: int = 6) -> list[str]: """Return the active FY and earlier FYs for the review selector.""" value = str(reference_fy or "").strip() match = re.fullmatch(r"(\d{4})-(\d{2})", value) if not match: return [value] if value else [] start_year = int(match.group(1)) return [ f"{year}-{str(year + 1)[-2:]}" for year in range(start_year, start_year - max(1, int(years_back)), -1) ] @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 = "", analysis_run_id: int = 0, financial_year: 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 "" today = date.today() active_fy = _financial_year_for_date(today) fy_options = _financial_year_options(active_fy, 7) requested_fy = str(financial_year or "").strip() if requested_fy and not re.fullmatch(r"\d{4}-\d{2}", requested_fy): command_error = command_error or "Invalid financial year selection." requested_fy = "" current_fy = requested_fy or active_fy previous_fy = _previous_financial_year(current_fy) previous_mirror_status = None current_mirror_status = None if selected and node and online: status_cache = {} def mirror_status_for(fy_value: str): if fy_value in status_cache: return status_cache[fy_value] response = request_agent_command( node.node_code, "accounting_mirror_status", _analysis_accounting_storage_payload(selected, fy_value, db=db, tenant_id=scope.tenant_id), timeout_seconds=20, ) status = (response.get("result") or {}) if response.get("ok") else {} status_cache[fy_value] = (status, response) return status, response try: # When the user has not selected a review FY explicitly, prefer # the active ERP FY only if its mirror exists. Otherwise use # the immediately preceding FY when that is the newest mirror # already available for the client. This prevents an existing # FY2025-26 mirror from being shown as "missing" merely because # the ERP header is now FY2026-27. if not requested_fy: active_status, active_response = mirror_status_for(active_fy) active_ready = bool((active_status.get("mirror") or {}).get("ready_for_financial_year")) if active_ready: current_fy = active_fy else: prior_active_fy = _previous_financial_year(active_fy) prior_status, prior_response = mirror_status_for(prior_active_fy) prior_ready = bool((prior_status.get("mirror") or {}).get("ready_for_financial_year")) if prior_ready: current_fy = prior_active_fy else: current_fy = active_fy previous_fy = _previous_financial_year(current_fy) current_mirror_status, current_response = mirror_status_for(current_fy) previous_mirror_status, previous_response = mirror_status_for(previous_fy) if not current_response.get("ok"): command_error = command_error or str( current_response.get("error") or "Current-year Accounting Mirror status failed." ) elif not previous_response.get("ok"): command_error = command_error or str( previous_response.get("error") or "Previous-year Accounting Mirror status failed." ) except Exception as exc: command_error = command_error or 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, "active_fy": active_fy, "fy_options": fy_options, "current_fy": current_fy, "previous_fy": previous_fy, "current_mirror_status": current_mirror_status, "previous_mirror_status": previous_mirror_status, "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_fy: str = Form(...), current_fy: 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_fy.strip() == current_fy.strip(): raise ValueError("Previous-year and current-year mirrors must be different.") 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.") previous_payload = _analysis_accounting_storage_payload(client, previous_fy, db=db, tenant_id=scope.tenant_id) current_payload = _analysis_accounting_storage_payload(client, current_fy, db=db, tenant_id=scope.tenant_id) response = request_agent_command( node.node_code, "accounting_opening_balance_mirror_snapshot", { **current_payload, "previous_accounting_relative_dir": previous_payload["accounting_relative_dir"], "current_accounting_relative_dir": current_payload["accounting_relative_dir"], }, timeout_seconds=180, ) if not response.get("ok"): raise RuntimeError(str(response.get("error") or "Opening balance mirror comparison 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, ) ledger_rows = ledger_items(db, run_id=run.id) stock_rows = stock_items(db, run_id=run.id) try: saved = request_agent_command( node.node_code, "accounting_analysis_save", { **current_payload, "analysis_type": "OPENING_BALANCE_REVIEW", "financial_year": current_fy, "period_from": previous_fy, "period_to": current_fy, "company_guid": run.current_company_guid, "company_name": run.current_company_name, "requested_by_user_id": int(user.id), "source_mirror_path": str(result.get("current_mirror_path") or ""), "parameters": {"previous_fy": previous_fy, "current_fy": current_fy, "erp_run_id": run.id}, "summary": json.loads(run.summary_json or "{}"), "result": { "erp_run_id": run.id, "previous_company": result.get("previous_company") or {}, "current_company": result.get("current_company") or {}, "ledgers": [ { "previous_name": x.previous_name, "current_name": x.current_name, "previous_closing_balance": x.previous_closing_balance, "current_opening_balance": x.current_opening_balance, "difference": x.difference, "status": x.status, } for x in ledger_rows ], "stock_items": [ { "previous_name": x.previous_name, "current_name": x.current_name, "previous_closing_qty": x.previous_closing_qty, "current_opening_qty": x.current_opening_qty, "previous_closing_value": x.previous_closing_value, "current_opening_value": x.current_opening_value, "status": x.status, } for x in stock_rows ], }, }, timeout_seconds=30, ) analysis_run_id = int(((saved.get("result") or {}).get("analysis_run_id") or 0)) if saved.get("ok") else 0 except Exception: analysis_run_id = 0 return _go( client.id, run_id=run.id, tab="summary", analysis_run_id=analysis_run_id, 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.") selected_financial_year = str(form.get("financial_year") or "").strip() response = request_agent_command( node.node_code, "accounting_opening_balance_apply", { **_analysis_accounting_storage_payload(client, selected_financial_year or None, db=db, tenant_id=scope.tenant_id), "current_company_name": run.current_company_name, "current_company_guid": run.current_company_guid, "source_key": f"OPENING-RUN-{run.id}", "requested_by_user_id": int(user.id), "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()