bmc_hub/app/products/backend/router.py

646 lines
22 KiB
Python
Raw Normal View History

"""
Products API
"""
from fastapi import APIRouter, HTTPException, Query
from typing import List, Dict, Any, Optional, Tuple
from app.core.database import execute_query, execute_query_single
from app.core.config import settings
import logging
import os
import aiohttp
logger = logging.getLogger(__name__)
router = APIRouter()
def _apigw_headers() -> Dict[str, str]:
token = settings.APIGW_TOKEN or os.getenv("APIGW_TOKEN") or os.getenv("APIGATEWAY_TOKEN")
if not token:
raise HTTPException(status_code=400, detail="APIGW_TOKEN is not configured")
return {"Authorization": f"Bearer {token}"}
def _normalize_query(raw_query: str) -> Tuple[str, List[str]]:
normalized = " ".join(
"".join(ch.lower() if ch.isalnum() else " " for ch in raw_query).split()
)
tokens = [token for token in normalized.split() if len(token) > 1]
return normalized, tokens
def _score_apigw_product(product: Dict[str, Any], normalized_query: str, tokens: List[str]) -> int:
if not normalized_query and not tokens:
return 0
name = str(product.get("product_name") or product.get("name") or "")
sku = str(product.get("sku") or "")
manufacturer = str(product.get("manufacturer") or "")
category = str(product.get("category") or "")
supplier = str(product.get("supplier_name") or "")
haystack = " ".join(
"".join(ch.lower() if ch.isalnum() else " " for ch in value).split()
for value in (name, sku, manufacturer, category, supplier)
if value
)
score = 0
if normalized_query and normalized_query in haystack:
score += 100
if tokens:
if all(token in haystack for token in tokens):
score += 50
for token in tokens:
if token in name.lower():
score += 5
elif token in haystack:
score += 2
if sku and sku.lower() == normalized_query:
score += 120
return score
@router.get("/products/apigateway/search", response_model=Dict[str, Any])
async def search_apigw_products(
q: Optional[str] = Query(None),
supplier_code: Optional[str] = Query(None),
min_price: Optional[float] = Query(None),
max_price: Optional[float] = Query(None),
in_stock: Optional[bool] = Query(None),
category: Optional[str] = Query(None),
manufacturer: Optional[str] = Query(None),
sort: Optional[str] = Query(None),
page: Optional[int] = Query(None),
per_page: Optional[int] = Query(None),
):
"""Search products via API Gateway and return raw results."""
params: Dict[str, Any] = {}
if q:
params["q"] = q
if supplier_code:
params["supplier_code"] = supplier_code
if min_price is not None:
params["min_price"] = min_price
if max_price is not None:
params["max_price"] = max_price
if in_stock is not None:
params["in_stock"] = str(in_stock).lower()
if category:
params["category"] = category
if manufacturer:
params["manufacturer"] = manufacturer
if sort:
params["sort"] = sort
if page is not None:
params["page"] = page
if per_page is not None:
params["per_page"] = per_page
if not params:
raise HTTPException(status_code=400, detail="Provide at least one search parameter")
base_url = settings.APIGW_BASE_URL or settings.APIGATEWAY_URL
url = f"{base_url.rstrip('/')}/api/v1/products/search"
logger.info("🔍 APIGW product search: %s", params)
timeout = aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=_apigw_headers(), params=params) as response:
if response.status >= 400:
detail = await response.text()
raise HTTPException(status_code=response.status, detail=detail)
data = await response.json()
if q and isinstance(data, dict) and isinstance(data.get("products"), list):
normalized_query, tokens = _normalize_query(q)
data["products"].sort(
key=lambda product: _score_apigw_product(product, normalized_query, tokens),
reverse=True,
)
return data
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error searching APIGW products: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/products/apigateway/import", response_model=Dict[str, Any])
async def import_apigw_product(payload: Dict[str, Any]):
"""Import a single APIGW product into local catalog."""
try:
product = payload.get("product") or payload
name = (product.get("product_name") or product.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="product_name is required")
supplier_code = product.get("supplier_code")
sku = product.get("sku")
sku_internal = f"{supplier_code}:{sku}" if supplier_code and sku else sku
if sku_internal:
existing = execute_query_single(
"SELECT * FROM products WHERE sku_internal = %s AND deleted_at IS NULL",
(sku_internal,)
)
if existing:
return existing
sales_price = product.get("price")
supplier_price = product.get("price")
insert_query = """
INSERT INTO products (
name,
short_description,
type,
status,
sku_internal,
ean,
manufacturer,
supplier_name,
supplier_sku,
supplier_price,
supplier_currency,
supplier_stock,
sales_price,
vat_rate,
billable
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
)
RETURNING *
"""
params = (
name,
product.get("category"),
"hardware",
"active",
sku_internal,
product.get("ean"),
product.get("manufacturer"),
product.get("supplier_name"),
sku,
supplier_price,
product.get("currency") or "DKK",
product.get("stock_qty"),
sales_price,
25.00,
True,
)
result = execute_query(insert_query, params)
return result[0] if result else {}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error importing APIGW product: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/products", response_model=List[Dict[str, Any]])
async def list_products(
status: Optional[str] = Query("active"),
q: Optional[str] = Query(None),
product_type: Optional[str] = Query(None, alias="type"),
manufacturer: Optional[str] = Query(None),
supplier_name: Optional[str] = Query(None),
sku: Optional[str] = Query(None),
ean: Optional[str] = Query(None),
billable: Optional[bool] = Query(None),
is_bundle: Optional[bool] = Query(None),
min_price: Optional[float] = Query(None),
max_price: Optional[float] = Query(None),
):
"""List products with optional search and filters."""
try:
conditions = ["deleted_at IS NULL"]
params = []
if status and status.lower() != "all":
conditions.append("status = %s")
params.append(status)
if q:
like = f"%{q.strip()}%"
conditions.append(
"(name ILIKE %s OR sku_internal ILIKE %s OR ean ILIKE %s OR manufacturer ILIKE %s OR supplier_name ILIKE %s)"
)
params.extend([like, like, like, like, like])
if product_type:
conditions.append("type = %s")
params.append(product_type)
if manufacturer:
conditions.append("manufacturer ILIKE %s")
params.append(f"%{manufacturer.strip()}%")
if supplier_name:
conditions.append("supplier_name ILIKE %s")
params.append(f"%{supplier_name.strip()}%")
if sku:
conditions.append("sku_internal ILIKE %s")
params.append(f"%{sku.strip()}%")
if ean:
conditions.append("ean ILIKE %s")
params.append(f"%{ean.strip()}%")
if billable is not None:
conditions.append("billable = %s")
params.append(billable)
if is_bundle is not None:
conditions.append("is_bundle = %s")
params.append(is_bundle)
if min_price is not None:
conditions.append("sales_price >= %s")
params.append(min_price)
if max_price is not None:
conditions.append("sales_price <= %s")
params.append(max_price)
where_clause = "WHERE " + " AND ".join(conditions)
query = f"""
SELECT
id,
uuid,
name,
short_description,
type,
status,
sku_internal,
ean,
manufacturer,
supplier_name,
supplier_price,
cost_price,
sales_price,
vat_rate,
billing_period,
auto_renew,
minimum_term_months,
is_bundle,
billable,
image_url
FROM products
{where_clause}
ORDER BY name ASC
"""
return execute_query(query, tuple(params)) or []
except Exception as e:
logger.error(f"❌ Error listing products: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/products", response_model=Dict[str, Any])
async def create_product(payload: Dict[str, Any]):
"""Create a product."""
try:
name = (payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
query = """
INSERT INTO products (
name,
short_description,
long_description,
type,
status,
sku_internal,
ean,
er_number,
manufacturer,
manufacturer_sku,
supplier_id,
supplier_name,
supplier_sku,
supplier_price,
supplier_currency,
supplier_stock,
supplier_lead_time_days,
supplier_updated_at,
cost_price,
sales_price,
vat_rate,
price_model,
price_override_allowed,
billing_period,
billing_anchor_month,
auto_renew,
minimum_term_months,
subscription_group_id,
is_bundle,
parent_product_id,
bundle_pricing_model,
billable,
default_case_tag,
default_time_rate_id,
category_id,
subcategory_id,
tags,
attributes_json,
technical_spec_json,
ai_classified,
ai_confidence,
ai_category_suggestion,
ai_tags_suggestion,
ai_classified_at,
image_url,
datasheet_url,
manual_url,
created_by,
updated_by
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s
)
RETURNING *
"""
params = (
name,
payload.get("short_description"),
payload.get("long_description"),
payload.get("type"),
payload.get("status", "active"),
payload.get("sku_internal"),
payload.get("ean"),
payload.get("er_number"),
payload.get("manufacturer"),
payload.get("manufacturer_sku"),
payload.get("supplier_id"),
payload.get("supplier_name"),
payload.get("supplier_sku"),
payload.get("supplier_price"),
payload.get("supplier_currency", "DKK"),
payload.get("supplier_stock"),
payload.get("supplier_lead_time_days"),
payload.get("supplier_updated_at"),
payload.get("cost_price"),
payload.get("sales_price"),
payload.get("vat_rate", 25.00),
payload.get("price_model"),
payload.get("price_override_allowed", False),
payload.get("billing_period"),
payload.get("billing_anchor_month"),
payload.get("auto_renew", False),
payload.get("minimum_term_months"),
payload.get("subscription_group_id"),
payload.get("is_bundle", False),
payload.get("parent_product_id"),
payload.get("bundle_pricing_model"),
payload.get("billable", True),
payload.get("default_case_tag"),
payload.get("default_time_rate_id"),
payload.get("category_id"),
payload.get("subcategory_id"),
payload.get("tags"),
payload.get("attributes_json"),
payload.get("technical_spec_json"),
payload.get("ai_classified", False),
payload.get("ai_confidence"),
payload.get("ai_category_suggestion"),
payload.get("ai_tags_suggestion"),
payload.get("ai_classified_at"),
payload.get("image_url"),
payload.get("datasheet_url"),
payload.get("manual_url"),
payload.get("created_by"),
payload.get("updated_by"),
)
result = execute_query(query, params)
return result[0] if result else {}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating product: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/products/{product_id}", response_model=Dict[str, Any])
async def get_product(product_id: int):
"""Get a single product."""
try:
query = "SELECT * FROM products WHERE id = %s AND deleted_at IS NULL"
product = execute_query_single(query, (product_id,))
if not product:
raise HTTPException(status_code=404, detail="Product not found")
return product
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error loading product: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/products/{product_id}/price-history", response_model=List[Dict[str, Any]])
async def list_product_price_history(product_id: int, limit: int = Query(100)):
"""List price history entries for a product."""
try:
query = """
SELECT
id,
product_id,
price_type,
old_price,
new_price,
note,
changed_by,
changed_at
FROM product_price_history
WHERE product_id = %s
ORDER BY changed_at DESC
LIMIT %s
"""
return execute_query(query, (product_id, limit)) or []
except Exception as e:
logger.error("❌ Error loading product price history: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/products/{product_id}/price", response_model=Dict[str, Any])
async def update_product_price(product_id: int, payload: Dict[str, Any]):
"""Update product sales price and record price history."""
try:
if "new_price" not in payload:
raise HTTPException(status_code=400, detail="new_price is required")
new_price = payload.get("new_price")
note = payload.get("note")
changed_by = payload.get("changed_by")
current = execute_query_single(
"SELECT sales_price FROM products WHERE id = %s AND deleted_at IS NULL",
(product_id,)
)
if not current:
raise HTTPException(status_code=404, detail="Product not found")
old_price = current.get("sales_price")
if old_price == new_price:
return {"status": "no_change", "sales_price": old_price}
update_query = """
UPDATE products
SET sales_price = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
"""
updated = execute_query(update_query, (new_price, product_id))
history_query = """
INSERT INTO product_price_history (
product_id,
price_type,
old_price,
new_price,
note,
changed_by
) VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
"""
history = execute_query(
history_query,
(product_id, "sales_price", old_price, new_price, note, changed_by)
)
return {
"status": "updated",
"product": updated[0] if updated else {},
"history": history[0] if history else {}
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error updating product price: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/products/{product_id}/supplier", response_model=Dict[str, Any])
async def update_product_supplier(product_id: int, payload: Dict[str, Any]):
"""Update supplier info and optionally record supplier price history."""
try:
supplier_name = payload.get("supplier_name")
supplier_price = payload.get("supplier_price")
note = payload.get("note")
changed_by = payload.get("changed_by")
current = execute_query_single(
"SELECT supplier_name, supplier_price FROM products WHERE id = %s AND deleted_at IS NULL",
(product_id,)
)
if not current:
raise HTTPException(status_code=404, detail="Product not found")
update_query = """
UPDATE products
SET supplier_name = %s,
supplier_price = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
"""
updated = execute_query(
update_query,
(
supplier_name if supplier_name is not None else current.get("supplier_name"),
supplier_price if supplier_price is not None else current.get("supplier_price"),
product_id,
)
)
history_entry = {}
if supplier_price is not None and current.get("supplier_price") != supplier_price:
history_query = """
INSERT INTO product_price_history (
product_id,
price_type,
old_price,
new_price,
note,
changed_by
) VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
"""
history = execute_query(
history_query,
(
product_id,
"supplier_price",
current.get("supplier_price"),
supplier_price,
note,
changed_by,
)
)
history_entry = history[0] if history else {}
return {
"status": "updated",
"product": updated[0] if updated else {},
"history": history_entry
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error updating supplier info: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/products/{product_id}/sales-history", response_model=List[Dict[str, Any]])
async def list_product_sales_history(product_id: int, limit: int = Query(100)):
"""List historical sales for a product from cases and subscriptions."""
try:
query = """
SELECT
'case_sale' AS source,
ss.id AS reference_id,
ss.sag_id,
COALESCE(ss.line_date, ss.created_at)::date AS line_date,
ss.description,
ss.quantity,
ss.unit_price,
ss.amount AS total_amount,
ss.currency,
ss.status
FROM sag_salgsvarer ss
WHERE ss.product_id = %s AND ss.type = 'sale'
UNION ALL
SELECT
'subscription' AS source,
ssi.id AS reference_id,
ss.sag_id,
ssi.created_at::date AS line_date,
ssi.description,
ssi.quantity,
ssi.unit_price,
ssi.line_total AS total_amount,
'DKK' AS currency,
ss.status
FROM sag_subscription_items ssi
JOIN sag_subscriptions ss ON ss.id = ssi.subscription_id
WHERE ssi.product_id = %s
ORDER BY line_date DESC
LIMIT %s
"""
return execute_query(query, (product_id, product_id, limit)) or []
except Exception as e:
logger.error("❌ Error loading product sales history: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))