Add Phase 21 stock item master mapping intelligence
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user