Files
arrr-erp/app/modules/accounting/opening_balance_ui.py
T

485 lines
17 KiB
Python

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