""" 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))