805 lines
33 KiB
Python
805 lines
33 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,
|
|
_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",
|
|
_accounting_storage_payload(selected, fy_value),
|
|
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 = _accounting_storage_payload(client, previous_fy)
|
|
current_payload = _accounting_storage_payload(client, current_fy)
|
|
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.")
|
|
|
|
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()
|