from __future__ import annotations from urllib.parse import urlencode from fastapi import APIRouter, Form, Request from fastapi.responses import RedirectResponse from sqlalchemy import select 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.stock_models import AccountingStockSyncRun from app.modules.accounting.stock_service import ( list_mappings, list_stock_groups, list_stock_items, list_unit_mappings, list_units, needs_mapping, persist_stock_snapshot, save_stock_mapping, save_unit_mapping, stock_summary, ) 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/stock-items", tags=["accounting-stock-item-intelligence-ui"], ) def _go(client_id=0, message="", error="", **filters): q = {} if client_id: q["client_id"] = client_id if message: q["message"] = message[:350] if error: q["error"] = error[:350] for key, value in filters.items(): if value not in (None, "", "all"): q[key] = value return RedirectResponse( "/tools/accounting/stock-items" + ("?" + urlencode(q) if q else ""), status_code=303, ) def _mapped_companies(accounting): rows = [] for mapping in (accounting or {}).get("mappings") or []: guid = str(mapping.get("tally_guid") or "").strip() if guid: rows.append( { "guid": guid, "name": str(mapping.get("company_name") or guid).strip(), } ) seen = set() unique = [] for row in rows: if row["guid"] not in seen: seen.add(row["guid"]) unique.append(row) return sorted(unique, key=lambda x: x["name"].casefold()) @router.get("") def page( request: Request, client_id: int | None = None, tally_guid: str = "", view: str = "items", q: str = "", group: str = "", hsn: str = "", page: int = 1, per_page: int = 50, 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( (client for client in clients if client_id and int(client.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 accounting = None 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"): result = response.get("result") or {} tally = result.get("tally") accounting = result.get("accounting") else: command_error = str(response.get("error") or "Local Agent status failed.") except Exception as exc: command_error = str(exc) companies = _mapped_companies(accounting) valid_guids = {row["guid"] for row in companies} if tally_guid and tally_guid not in valid_guids: tally_guid = "" if not tally_guid and len(companies) == 1: tally_guid = companies[0]["guid"] summary = {"groups": 0, "items": 0, "units": 0, "mappings": 0, "unit_mappings": 0} items = [] groups = [] units = [] mappings = [] unit_mappings = [] review_rows = [] all_item_options = [] total = 0 pages = 1 last_sync = None if selected and tally_guid: summary = stock_summary( db, tenant_id=scope.tenant_id, client_id=selected.id, tally_guid=tally_guid, ) groups = list_stock_groups( db, tenant_id=scope.tenant_id, client_id=selected.id, tally_guid=tally_guid, ) all_item_options, _all_total, _all_page, _all_pages = list_stock_items( db, tenant_id=scope.tenant_id, client_id=selected.id, tally_guid=tally_guid, page=1, per_page=200, ) units = list_units( db, tenant_id=scope.tenant_id, client_id=selected.id, tally_guid=tally_guid, ) if view == "items": items, total, page, pages = list_stock_items( db, tenant_id=scope.tenant_id, client_id=selected.id, tally_guid=tally_guid, q=q, group=group, hsn=hsn, page=page, per_page=per_page, ) elif view == "mappings": mappings = list_mappings( db, tenant_id=scope.tenant_id, client_id=selected.id, tally_guid=tally_guid, ) elif view == "units": unit_mappings = list_unit_mappings( db, tenant_id=scope.tenant_id, client_id=selected.id, tally_guid=tally_guid, ) elif view == "review": review_rows = needs_mapping( db, tenant_id=scope.tenant_id, client_id=selected.id, tally_guid=tally_guid, limit=200, ) last_sync = db.execute( select(AccountingStockSyncRun) .where( AccountingStockSyncRun.tenant_id == scope.tenant_id, AccountingStockSyncRun.client_id == selected.id, AccountingStockSyncRun.tally_guid == tally_guid, ) .order_by(AccountingStockSyncRun.id.desc()) .limit(1) ).scalar_one_or_none() return templates.TemplateResponse( "modules/accounting/templates/accounting/stock_items.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": "Stock Item Intelligence", "clients": clients, "selected_client": selected, "node_online": online, "tally": tally, "accounting": accounting, "companies": companies, "selected_tally_guid": tally_guid, "summary": summary, "items": items, "groups": groups, "units": units, "mappings": mappings, "unit_mappings": unit_mappings, "review_rows": review_rows, "all_item_options": all_item_options, "view": view if view in {"items", "mappings", "review", "units"} else "items", "q": q, "group_filter": group, "hsn_filter": hsn, "page": page, "pages": pages, "per_page": per_page, "total": total, "last_sync": last_sync, "message": message, "error": command_error, }, ) finally: db.close() @router.post("/sync") def sync_stock_items( request: Request, client_id: int = Form(...), tally_guid: 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.") node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) if not node or not _node_online(node): return _go(client.id, error="ERP Local Agent is offline for the active branch.") response = request_agent_command( node.node_code, "accounting_stock_master_intelligence", { **_accounting_storage_payload(client), "tally_guid": str(tally_guid or "").strip(), "requested_by_user_id": int(user.id), }, timeout_seconds=180, ) if not response.get("ok"): raise RuntimeError(str(response.get("error") or "Stock master synchronization failed.")) result = response.get("result") or {} snapshot = result.get("stock_master") or {} run = persist_stock_snapshot( db, tenant_id=scope.tenant_id, client_id=client.id, tally_guid=str(tally_guid or "").strip(), company_name=str(snapshot.get("company_name") or "").strip(), stock_groups=list(snapshot.get("stock_groups") or []), stock_items=list(snapshot.get("stock_items") or []), units=list(snapshot.get("units") or []), user_id=user.id, ) return _go( client.id, tally_guid=tally_guid, view="items", message=( f"Stock master synchronized: {run.stock_item_count} item(s), " f"{run.stock_group_count} group(s), {run.unit_count} unit(s)." ), ) except Exception as exc: db.rollback() return _go(client_id, tally_guid=tally_guid, error=str(exc)) finally: db.close() @router.post("/mapping") def save_mapping( request: Request, client_id: int = Form(...), tally_guid: str = Form(...), source_type: str = Form("generic"), party_key: str = Form(""), source_description: str = Form(...), hsn_sac: str = Form(""), stock_item_id: int = 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.") mapping = save_stock_mapping( db, tenant_id=scope.tenant_id, client_id=client.id, tally_guid=tally_guid, source_type=source_type, party_key=party_key, source_description=source_description, hsn_sac=hsn_sac, stock_item_id=stock_item_id, user_id=user.id, note=note, ) return _go( client.id, tally_guid=tally_guid, view="review", message=f"Stock-item mapping saved for '{mapping.source_description}'.", ) except Exception as exc: db.rollback() return _go(client_id, tally_guid=tally_guid, view="review", error=str(exc)) finally: db.close() @router.post("/unit-mapping") def unit_mapping( request: Request, client_id: int = Form(...), tally_guid: str = Form(...), source_unit: str = Form(...), target_unit_id: int = Form(...), quantity_factor: float = Form(1.0), 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.") mapping = save_unit_mapping( db, tenant_id=scope.tenant_id, client_id=client.id, tally_guid=tally_guid, source_unit=source_unit, target_unit_id=target_unit_id, quantity_factor=quantity_factor, user_id=user.id, note=note, ) return _go( client.id, tally_guid=tally_guid, view="units", message=f"Unit mapping saved for '{mapping.source_unit}'.", ) except Exception as exc: db.rollback() return _go(client_id, tally_guid=tally_guid, view="units", error=str(exc)) finally: db.close()