646 lines
22 KiB
Python
646 lines
22 KiB
Python
|
|
"""
|
||
|
|
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))
|