Files
2026-09-20 16:36:14 +05:30

809 lines
34 KiB
Python

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()