""" Products API """ from fastapi import APIRouter, HTTPException, Query, Depends from typing import List, Dict, Any, Optional, Tuple from app.core.database import execute_query, execute_query_single from app.core.config import settings from app.core.auth_dependencies import require_permission import logging import os import aiohttp import json import asyncio logger = logging.getLogger(__name__) from app.products.backend.economic_access import ProductAccessRoute router = APIRouter(route_class=ProductAccessRoute) 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 _apigw_base_url() -> str: base_url = ( settings.APIGW_BASE_URL or settings.APIGATEWAY_URL or os.getenv("APIGW_BASE_URL") or os.getenv("APIGATEWAY_URL") or "" ).strip() if not base_url: raise HTTPException(status_code=500, detail="API Gateway base URL is not configured") return base_url.rstrip("/") def _extract_error_detail(payload: Any) -> str: if payload is None: return "" if isinstance(payload, str): return payload.strip() if isinstance(payload, dict): for key in ("detail", "message", "error", "description"): value = payload.get(key) if isinstance(value, str) and value.strip(): return value.strip() return json.dumps(payload, ensure_ascii=False) if isinstance(payload, list): parts = [_extract_error_detail(item) for item in payload] cleaned = [part for part in parts if part] return "; ".join(cleaned) return str(payload).strip() async def _read_apigw_error(response: aiohttp.ClientResponse) -> str: try: body = await response.json(content_type=None) detail = _extract_error_detail(body) if detail: return detail except Exception: pass text = (await response.text() or "").strip() if text: return text return f"API Gateway request failed (HTTP {response.status})" def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: str = "manual") -> Dict[str, Any]: supplier_name = payload.get("supplier_name") supplier_code = payload.get("supplier_code") supplier_sku = payload.get("supplier_sku") or payload.get("sku") supplier_price = payload.get("supplier_price") if payload.get("supplier_price") is not None else payload.get("price") supplier_currency = payload.get("supplier_currency") or payload.get("currency") or "DKK" supplier_stock = payload.get("supplier_stock") if payload.get("supplier_stock") is not None else payload.get("stock_qty") variant_key = str(payload.get('variant_key') or '') license_term = str(payload.get('license_term') or '') billing_interval = str(payload.get('billing_interval') or '') market = str(payload.get('market') or '').upper() valid_until = payload.get('valid_until') or None if supplier_price is not None: from app.products.backend.economic_catalog import decimal supplier_price = decimal(supplier_price) if supplier_price < 0: raise HTTPException(422, 'Leverandørprisen må ikke være negativ') if valid_until: from datetime import date try: valid_until = date.fromisoformat(str(valid_until)) except ValueError: raise HTTPException(422, 'Ugyldig gyldighedsdato') supplier_url = payload.get("supplier_url") or payload.get("supplier_link") supplier_product_url = ( payload.get("supplier_product_url") or payload.get("product_url") or payload.get("product_link") or payload.get("url") ) match_query = None match_params = None if supplier_code and supplier_sku: match_query = """ SELECT * FROM product_suppliers WHERE product_id = %s AND supplier_code = %s AND supplier_sku = %s LIMIT 1 """ match_params = (product_id, supplier_code, supplier_sku) elif supplier_url: match_query = """ SELECT * FROM product_suppliers WHERE product_id = %s AND supplier_url = %s LIMIT 1 """ match_params = (product_id, supplier_url) elif supplier_name and supplier_sku: match_query = """ SELECT * FROM product_suppliers WHERE product_id = %s AND supplier_name = %s AND supplier_sku = %s LIMIT 1 """ match_params = (product_id, supplier_name, supplier_sku) existing = None if match_query and match_params is not None: match_query = match_query.replace('LIMIT 1', "AND COALESCE(variant_key,'')=%s AND COALESCE(license_term,'')=%s AND COALESCE(billing_interval,'')=%s AND COALESCE(market,'')=%s LIMIT 1") match_params = (*match_params, variant_key, license_term, billing_interval, market) existing = execute_query_single(match_query, match_params) if existing: update_query = """ UPDATE product_suppliers SET supplier_name = %s, supplier_code = %s, supplier_sku = %s, supplier_price = %s, supplier_currency = %s, supplier_stock = %s, supplier_url = %s, supplier_product_url = %s, source = %s, variant_key = %s, license_term = %s, billing_interval = %s, market = %s, valid_until = %s, last_updated_at = CURRENT_TIMESTAMP WHERE id = %s RETURNING * """ result = execute_query( update_query, ( supplier_name, supplier_code, supplier_sku, supplier_price, supplier_currency, supplier_stock, supplier_url, supplier_product_url, source, variant_key, license_term, billing_interval, market, valid_until, existing.get("id"), ) ) return result[0] if result else existing insert_query = """ INSERT INTO product_suppliers ( product_id, supplier_name, supplier_code, supplier_sku, supplier_price, supplier_currency, supplier_stock, supplier_url, supplier_product_url, source, variant_key, license_term, billing_interval, market, valid_until, last_updated_at ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP) ON CONFLICT DO NOTHING RETURNING * """ result = execute_query( insert_query, ( product_id, supplier_name, supplier_code, supplier_sku, supplier_price, supplier_currency, supplier_stock, supplier_url, supplier_product_url, source, variant_key, license_term, billing_interval, market, valid_until, ) ) return result[0] if result else (execute_query_single(match_query, match_params) if match_query else {}) def _log_product_audit(product_id: int, event_type: str, user_id: Optional[int], changes: Dict[str, Any]) -> None: audit_query = """ INSERT INTO product_audit_log (product_id, event_type, user_id, changes) VALUES (%s, %s, %s, %s) """ execute_query(audit_query, (product_id, event_type, user_id, json.dumps(changes))) 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("".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 def _normalize_lookup_code(raw: Optional[str]) -> str: return "".join(ch for ch in str(raw or "") if ch.isalnum()).lower() def _find_local_product_by_lookup(code: str) -> Optional[Dict[str, Any]]: normalized = _normalize_lookup_code(code) if not normalized: return None query = """ SELECT * FROM products WHERE deleted_at IS NULL AND ( LOWER(REGEXP_REPLACE(COALESCE(ean, ''), '[^a-zA-Z0-9]', '', 'g')) = %s OR LOWER(REGEXP_REPLACE(COALESCE(sku_internal, ''), '[^a-zA-Z0-9]', '', 'g')) = %s ) ORDER BY CASE WHEN status = 'active' THEN 0 ELSE 1 END, id ASC LIMIT 1 """ return execute_query_single(query, (normalized, normalized)) def _pick_best_apigw_match(products: List[Dict[str, Any]], query: str) -> Optional[Dict[str, Any]]: if not products: return None normalized_query = _normalize_lookup_code(query) if not normalized_query: return products[0] exact_matches: List[Dict[str, Any]] = [] sku_matches: List[Dict[str, Any]] = [] for product in products: ean_norm = _normalize_lookup_code(product.get("ean")) sku_norm = _normalize_lookup_code(product.get("sku")) if ean_norm and ean_norm == normalized_query: exact_matches.append(product) elif sku_norm and sku_norm == normalized_query: sku_matches.append(product) if exact_matches: return exact_matches[0] if sku_matches: return sku_matches[0] return products[0] def _get_customer_margin_percent(customer_id: Optional[int]) -> Optional[float]: if not customer_id: return None customer = execute_query_single( "SELECT standard_margin_percent FROM customers WHERE id = %s", (customer_id,), ) if not customer: return None margin_raw = customer.get("standard_margin_percent") if margin_raw is None: return None try: return float(margin_raw) except (TypeError, ValueError): return None def _calculate_sales_price_with_margin(base_price: Any, margin_percent: Optional[float]) -> Optional[float]: if base_price is None: return None try: base = float(base_price) except (TypeError, ValueError): return None if margin_percent is None: return base price = base * (1 + (float(margin_percent) / 100.0)) return round(price, 2) def _first_apigw_value(product: Dict[str, Any], *keys: str) -> Any: for key in keys: value = product.get(key) if value is not None and (not isinstance(value, str) or value.strip()): return value return None def _unwrap_apigw_product(payload: Dict[str, Any]) -> Dict[str, Any]: """Accept both the Hub wrapper and common API Gateway item wrappers.""" product: Any = payload identity_keys = ( "product_name", "name", "productName", "title", "sku", "supplier_sku", "item_number", "part_number", "ean", "gtin", "barcode", ) for _ in range(4): if isinstance(product, str): try: product = json.loads(product) except (TypeError, ValueError): break if not isinstance(product, dict): break if any(_first_apigw_value(product, key) is not None for key in identity_keys): break nested = next( ( product.get(key) for key in ("product", "item", "result", "data") if isinstance(product.get(key), (dict, str)) ), None, ) if nested is None: break product = nested return dict(product) if isinstance(product, dict) else {} def _normalize_apigw_product(payload: Dict[str, Any]) -> Dict[str, Any]: """Normalize supplier variants before rendering or importing a product.""" product = _unwrap_apigw_product(payload) normalized = dict(product) supplier = product.get("supplier") if isinstance(product.get("supplier"), dict) else {} primary_category = product.get("primary_category") if isinstance(product.get("primary_category"), dict) else {} aliases = { "product_name": _first_apigw_value( product, "product_name", "name", "productName", "title", "description", "short_description", ), "sku": _first_apigw_value(product, "sku", "supplier_sku", "item_number", "part_number"), "ean": _first_apigw_value(product, "ean", "ean13", "gtin", "barcode"), "price": _first_apigw_value(product, "price", "supplier_price", "net_price", "cost_price", "unit_price"), "currency": _first_apigw_value(product, "currency", "supplier_currency"), "stock_qty": _first_apigw_value(product, "stock_qty", "stock", "quantity", "available_stock"), "category": ( _first_apigw_value(product, "category", "legacy_category") or primary_category.get("name") ), "supplier_name": _first_apigw_value(product, "supplier_name") or supplier.get("name"), "supplier_code": _first_apigw_value(product, "supplier_code") or supplier.get("code"), "product_url": _first_apigw_value( product, "product_url", "product_link", "supplier_product_url", "supplier_url", "url", ), } for key, value in aliases.items(): if value is not None and (not isinstance(value, str) or value.strip()): normalized[key] = value.strip() if isinstance(value, str) else value if not str(normalized.get("product_name") or "").strip(): sku = str(normalized.get("sku") or normalized.get("ean") or "").strip() descriptor = str( normalized.get("manufacturer") or normalized.get("supplier_name") or "Produkt" ).strip() if sku: normalized["product_name"] = f"{descriptor} · {sku}" return normalized def _import_apigw_product_to_local(payload: Dict[str, Any], customer_id: Optional[int] = None) -> Dict[str, Any]: product = _normalize_apigw_product(payload) name = str(product.get("product_name") or "").strip() if not name: logger.warning("APIGW product import rejected because no usable name or item code was present; keys=%s", sorted(product.keys())) raise HTTPException( status_code=400, detail="Produktet mangler både navn og varenummer og kan derfor ikke importeres", ) 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_by_sku = execute_query_single( "SELECT * FROM products WHERE sku_internal = %s AND deleted_at IS NULL", (sku_internal,) ) if existing_by_sku: _upsert_product_supplier(existing_by_sku["id"], product, source="gateway") return existing_by_sku ean = str(product.get("ean") or "").strip() if ean: existing_by_ean = execute_query_single( "SELECT * FROM products WHERE ean = %s AND deleted_at IS NULL", (ean,) ) if existing_by_ean: _upsert_product_supplier(existing_by_ean["id"], product, source="gateway") return existing_by_ean margin_percent = _get_customer_margin_percent(customer_id) sales_price = _calculate_sales_price_with_margin(product.get("price"), margin_percent) 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, ean or None, 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) created = result[0] if result else {} if created: _upsert_product_supplier(created["id"], product, source="gateway") if created and margin_percent is not None: created["applied_customer_margin_percent"] = margin_percent return created @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") url = f"{_apigw_base_url()}/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 _read_apigw_error(response) raise HTTPException( status_code=502, detail=f"API Gateway product search failed ({response.status}): {detail}", ) data = await response.json() if isinstance(data, dict) and isinstance(data.get("products"), list): data["products"] = [ _normalize_apigw_product(product) for product in data["products"] if isinstance(product, dict) ] if q: 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 asyncio.TimeoutError: logger.error("❌ APIGW product search timeout for params: %s", params, exc_info=True) raise HTTPException(status_code=504, detail="API Gateway product search timed out") except aiohttp.ClientError as e: logger.error("❌ APIGW product search connection error: %s", e, exc_info=True) raise HTTPException(status_code=502, detail=f"API Gateway connection failed: {e}") except Exception as e: logger.error("❌ Error searching APIGW products: %s", e, exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/products/search/apigateway-sync", response_model=Dict[str, Any]) async def search_or_create_product_from_apigw( code: str = Query(..., min_length=2), auto_create: bool = Query(True), customer_id: Optional[int] = Query(None), ): """ Local-first product lookup by EAN/SKU-like code. If not found locally, search API Gateway and optionally auto-create locally from best match. """ search_code = (code or "").strip() if not search_code: raise HTTPException(status_code=400, detail="code is required") local_product = _find_local_product_by_lookup(search_code) if local_product: return { "found": True, "source": "local", "created": False, "query": search_code, "product": local_product, } timeout = aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS) url = f"{_apigw_base_url()}/api/v1/products/search" params = {"q": search_code, "per_page": 25} 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 _read_apigw_error(response) raise HTTPException( status_code=502, detail=f"API Gateway product search failed ({response.status}): {detail}", ) data = await response.json() except HTTPException: raise except asyncio.TimeoutError: raise HTTPException(status_code=504, detail="API Gateway product search timed out") except aiohttp.ClientError as e: raise HTTPException(status_code=502, detail=f"API Gateway connection failed: {e}") except Exception as e: logger.error("❌ APIGW sync search failed: %s", e, exc_info=True) raise HTTPException(status_code=500, detail=str(e)) products = data.get("products") if isinstance(data, dict) else [] products = products if isinstance(products, list) else [] products = [ _normalize_apigw_product(product) for product in products if isinstance(product, dict) ] best_match = _pick_best_apigw_match(products, search_code) if not best_match: return { "found": False, "source": "apigateway", "created": False, "query": search_code, "message": "Ingen match i API Gateway", } if not auto_create: return { "found": True, "source": "apigateway", "created": False, "query": search_code, "product": best_match, } created_or_existing = _import_apigw_product_to_local(best_match, customer_id=customer_id) applied_margin = created_or_existing.get("applied_customer_margin_percent") if isinstance(created_or_existing, dict) else None return { "found": True, "source": "apigateway", "created": True, "query": search_code, "customer_id": customer_id, "applied_customer_margin_percent": applied_margin, "product": created_or_existing, } @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: return _import_apigw_product_to_local(payload) 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, serial_number_required, asset_required, rental_asset_enabled, attributes_json, 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, serial_number_required, asset_required, rental_asset_enabled, 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, %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("serial_number_required", False), payload.get("asset_required", False), payload.get("rental_asset_enabled", False), 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.patch("/products/{product_id}", response_model=Dict[str, Any]) async def update_product( product_id: int, payload: Dict[str, Any], current_user: dict = Depends(require_permission("products.update")) ): """Update product fields for core metadata and billing validation flags.""" try: if any(key in payload for key in ('economic_product_number', 'economic_connection_id')): raise HTTPException(409, 'Den permanente e-conomic-kobling kan ikke ændres ved almindelig produktredigering') name = payload.get("name") if name is not None: name = name.strip() if not name or len(name) > 255: raise HTTPException(status_code=400, detail="Navnet skal være 1–255 tegn") serial_number_required = payload.get("serial_number_required") asset_required = payload.get("asset_required") rental_asset_enabled = payload.get("rental_asset_enabled") existing = execute_query_single( """ SELECT name, serial_number_required, asset_required, rental_asset_enabled FROM products WHERE id = %s AND deleted_at IS NULL """, (product_id,) ) if not existing: raise HTTPException(status_code=404, detail="Product not found") updates = ["updated_at = CURRENT_TIMESTAMP", "updated_by = %s"] values: List[Any] = [current_user.get("id")] if name is not None: updates.append("name = %s") values.append(name) if serial_number_required is not None: updates.append("serial_number_required = %s") values.append(bool(serial_number_required)) if asset_required is not None: updates.append("asset_required = %s") values.append(bool(asset_required)) if rental_asset_enabled is not None: updates.append("rental_asset_enabled = %s") values.append(bool(rental_asset_enabled)) values.append(product_id) query = f""" UPDATE products SET {', '.join(updates)} WHERE id = %s AND deleted_at IS NULL RETURNING * """ result = execute_query(query, tuple(values)) if not result: raise HTTPException(status_code=404, detail="Product not found") if name is not None and name != existing.get("name"): _log_product_audit( product_id, "name_updated", current_user.get("id") if current_user else None, {"old": existing.get("name"), "new": name} ) for field in ("serial_number_required", "asset_required", "rental_asset_enabled"): if field in payload and payload.get(field) != existing.get(field): _log_product_audit( product_id, f"{field}_updated", current_user.get("id") if current_user else None, {"old": existing.get(field), "new": bool(payload.get(field))} ) return result[0] except HTTPException: raise except Exception as e: logger.error("❌ Error updating product: %s", e, exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.delete("/products/{product_id}", response_model=Dict[str, Any]) async def delete_product( product_id: int, current_user: dict = Depends(require_permission("products.delete")) ): """Soft-delete a product.""" try: query = """ UPDATE products SET deleted_at = CURRENT_TIMESTAMP, status = 'inactive', updated_at = CURRENT_TIMESTAMP WHERE id = %s AND deleted_at IS NULL RETURNING id """ result = execute_query(query, (product_id,)) if not result: raise HTTPException(status_code=404, detail="Product not found") _log_product_audit( product_id, "deleted", current_user.get("id") if current_user else None, {"status": "inactive"} ) return {"status": "deleted", "id": result[0].get("id")} except HTTPException: raise except Exception as e: logger.error("❌ Error deleting product: %s", e, exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/products/{product_id}/suppliers", response_model=List[Dict[str, Any]]) async def list_product_suppliers(product_id: int): """List suppliers for a product.""" try: query = """ SELECT id, product_id, supplier_name, supplier_code, supplier_sku, supplier_price, supplier_currency, supplier_stock, supplier_url, supplier_product_url, source, last_updated_at FROM product_suppliers WHERE product_id = %s ORDER BY supplier_name NULLS LAST, supplier_price NULLS LAST """ return execute_query(query, (product_id,)) or [] except Exception as e: logger.error("❌ Error loading product suppliers: %s", e, exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post("/products/{product_id}/suppliers", response_model=Dict[str, Any]) async def upsert_product_supplier(product_id: int, payload: Dict[str, Any]): """Create or update a product supplier.""" try: return _upsert_product_supplier(product_id, payload, source=payload.get("source") or "manual") except Exception as e: logger.error("❌ Error saving product supplier: %s", e, exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.delete("/products/{product_id}/suppliers/{supplier_id}", response_model=Dict[str, Any]) async def delete_product_supplier(product_id: int, supplier_id: int): """Delete a product supplier.""" try: query = "DELETE FROM product_suppliers WHERE id = %s AND product_id = %s" execute_query(query, (supplier_id, product_id)) return {"status": "deleted"} except Exception as e: logger.error("❌ Error deleting product supplier: %s", e, exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post("/products/{product_id}/suppliers/refresh", response_model=Dict[str, Any]) async def refresh_product_suppliers(product_id: int): """Refresh suppliers from API Gateway using EAN only.""" try: product = execute_query_single( "SELECT ean FROM products WHERE id = %s AND deleted_at IS NULL", (product_id,), ) if not product: raise HTTPException(status_code=404, detail="Product not found") ean = (product.get("ean") or "").strip() if not ean: raise HTTPException(status_code=400, detail="Product has no EAN to search") base_url = settings.APIGW_BASE_URL or settings.APIGATEWAY_URL url = f"{base_url.rstrip('/')}/api/v1/products/search" params = {"q": ean, "per_page": 25} timeout = aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS) 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() results = data.get("products") if isinstance(data, dict) else [] if not isinstance(results, list): results = [] saved = 0 seen_keys = set() for item in results: key = ( item.get("supplier_code"), item.get("sku"), item.get("product_url") or item.get("url") ) if key in seen_keys: continue seen_keys.add(key) _upsert_product_supplier(product_id, item, source="gateway") saved += 1 return { "status": "refreshed", "saved": saved, "queries": [{"q": ean}] } except HTTPException: raise except Exception as e: logger.error("❌ Error refreshing product suppliers: %s", 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))