379 lines
13 KiB
Python
379 lines
13 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
|
|
|
|
|
|
@router.get("")
|
|
def page(
|
|
request: Request,
|
|
client_id: int | None = None,
|
|
run_id: int | None = None,
|
|
tab: str = "ledgers",
|
|
status: str = "",
|
|
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 = []
|
|
summary = {}
|
|
|
|
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 = json.loads(selected_run.summary_json or "{}")
|
|
except Exception:
|
|
summary = {}
|
|
if tab == "stock":
|
|
stocks = stock_items(db, run_id=selected_run.id, status=status)
|
|
else:
|
|
ledgers = ledger_items(db, run_id=selected_run.id, status=status)
|
|
|
|
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,
|
|
"summary": summary,
|
|
"tab": tab if tab in {"ledgers", "stock"} else "ledgers",
|
|
"status_filter": status,
|
|
"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="ledgers",
|
|
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="ledgers",
|
|
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()
|