Add Phase 21 stock item master mapping intelligence

This commit is contained in:
A R R R Associates
2026-08-24 22:07:16 +05:30
parent b8fbaf55ec
commit 0fab925e71
11 changed files with 1777 additions and 10 deletions
+418
View File
@@ -0,0 +1,418 @@
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()