@@ -4349,5 +4552,422 @@
+
+
{% endblock %}
diff --git a/app/products/backend/__init__.py b/app/products/backend/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/products/backend/router.py b/app/products/backend/router.py
new file mode 100644
index 0000000..229aae0
--- /dev/null
+++ b/app/products/backend/router.py
@@ -0,0 +1,645 @@
+"""
+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))
diff --git a/app/products/frontend/__init__.py b/app/products/frontend/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/products/frontend/detail.html b/app/products/frontend/detail.html
new file mode 100644
index 0000000..bbe8460
--- /dev/null
+++ b/app/products/frontend/detail.html
@@ -0,0 +1,366 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Produkt - BMC Hub{% endblock %}
+
+{% block extra_css %}
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
Opdater pris
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Opdater leverandoer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Pris historik
+
+
+
+
+ | Dato |
+ Type |
+ Gammel |
+ Ny |
+ Note |
+ Af |
+
+
+
+ | Indlaeser... |
+
+
+
+
+
+
+
Tidligere salg
+
+
+
+
+ | Dato |
+ Kilde |
+ Beskrivelse |
+ Antal |
+ Pris |
+ Total |
+ Status |
+
+
+
+ | Indlaeser... |
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/products/frontend/list.html b/app/products/frontend/list.html
new file mode 100644
index 0000000..e5ed07d
--- /dev/null
+++ b/app/products/frontend/list.html
@@ -0,0 +1,1058 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Produkter - BMC Hub{% endblock %}
+
+{% block extra_css %}
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
Katalog
+
Produkter
+
Opret og vedligehold produkter til abonnementer, service og salgslinjer. Alt samlet, sporbar og klar til brug i sager.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Navn |
+ Type |
+ Status |
+ SKU |
+ Pris |
+ Interval |
+
+
+
+
+ |
+ Indlaeser...
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Soeg efter produkter for at importere.
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/products/frontend/views.py b/app/products/frontend/views.py
new file mode 100644
index 0000000..bcf7405
--- /dev/null
+++ b/app/products/frontend/views.py
@@ -0,0 +1,24 @@
+"""
+Products Frontend Views
+"""
+from fastapi import APIRouter, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+
+router = APIRouter()
+templates = Jinja2Templates(directory="app")
+
+
+@router.get("/products", response_class=HTMLResponse)
+async def products_list(request: Request):
+ return templates.TemplateResponse("products/frontend/list.html", {
+ "request": request
+ })
+
+
+@router.get("/products/{product_id}", response_class=HTMLResponse)
+async def product_detail(request: Request, product_id: int):
+ return templates.TemplateResponse("products/frontend/detail.html", {
+ "request": request,
+ "product_id": product_id
+ })
diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html
index 13081df..a24c6c1 100644
--- a/app/shared/frontend/base.html
+++ b/app/shared/frontend/base.html
@@ -248,6 +248,7 @@
Ny Ticket
Prepaid Cards
Fastpris Aftaler
+
Abonnementer
Knowledge Base
@@ -259,7 +260,7 @@