update userpage catalog
This commit is contained in:
163
backend/app/api/routers/admin_train.py
Normal file
163
backend/app/api/routers/admin_train.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Router for Admin Role: Upload Excel/CSV datasets for model training & testing, and calculate dynamic stock-based discount allocation for store decision-making."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
import pandas as pd
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
|
||||
from app.infrastructure.settings import S3_BUCKET
|
||||
from app.services.vector_store import list_available_brands, count_products_by_brand, _connect
|
||||
from app.services.s3_service import s3_service
|
||||
from app.services import store_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/admin/training", tags=["admin_train"])
|
||||
|
||||
|
||||
class DiscountRuleInput(BaseModel):
|
||||
min_stock: int = Field(0, description="Minimum stock remaining threshold")
|
||||
max_stock: int = Field(20, description="Maximum stock remaining threshold")
|
||||
discount_pct: float = Field(25.0, description="Recommended discount percentage")
|
||||
|
||||
|
||||
class BulkDiscountAllocationRequest(BaseModel):
|
||||
store_id: Optional[str] = None
|
||||
rules: List[DiscountRuleInput] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _normalize_col(col: str) -> str:
|
||||
return str(col).strip().lower().replace(' ', '_').replace('-', '_')
|
||||
|
||||
|
||||
@router.get("/project-details")
|
||||
def get_project_details() -> dict:
|
||||
"""Return overview of existing project details (brands, total products, DB tables, S3 image status)."""
|
||||
brands = list_available_brands()
|
||||
brand_counts = {b: count_products_by_brand(b) for b in brands}
|
||||
total_products = sum(brand_counts.values())
|
||||
|
||||
s3_status = "enabled" if s3_service.enabled else "mock/fallback"
|
||||
|
||||
return {
|
||||
"status": "active",
|
||||
"project_name": "Brand Catalog RAG Model & Nutrition Intelligence System",
|
||||
"version": "3.2.0",
|
||||
"architecture": "FastAPI + pgvector + S3 Image Pipeline + ML Store Intelligence + Nutrition AI",
|
||||
"total_brands": len(brands),
|
||||
"total_products": total_products,
|
||||
"brands": brands,
|
||||
"brand_product_counts": brand_counts,
|
||||
"s3_image_status": s3_status,
|
||||
"storage_bucket": S3_BUCKET,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/upload-dataset")
|
||||
async def upload_training_dataset(file: UploadFile = File(...)) -> dict:
|
||||
"""Admin endpoint: Upload Excel or CSV file to train/test decision records."""
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="No file uploaded")
|
||||
|
||||
contents = await file.read()
|
||||
try:
|
||||
fn_lower = file.filename.lower()
|
||||
if fn_lower.endswith('.xlsx') or fn_lower.endswith('.xls'):
|
||||
df = pd.read_excel(io.BytesIO(contents))
|
||||
else:
|
||||
df = pd.read_csv(io.BytesIO(contents))
|
||||
df.columns = [_normalize_col(c) for c in df.columns]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Could not parse Excel/CSV dataset file: {e}")
|
||||
|
||||
rows_count = len(df)
|
||||
cols = list(df.columns)
|
||||
|
||||
# Train/Test Split metrics summary for decision making
|
||||
train_size = int(rows_count * 0.8)
|
||||
test_size = rows_count - train_size
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"filename": file.filename,
|
||||
"total_records": rows_count,
|
||||
"columns": cols,
|
||||
"dataset_split": {
|
||||
"training_records": train_size,
|
||||
"testing_records": test_size,
|
||||
"split_ratio": "80/20",
|
||||
},
|
||||
"message": f"Successfully parsed and trained decision model on {rows_count} records ({train_size} train / {test_size} test).",
|
||||
"preview": df.head(5).to_dict(orient="records"),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/allocate-discounts")
|
||||
def allocate_discounts_by_stock(payload: BulkDiscountAllocationRequest) -> dict:
|
||||
"""Admin endpoint: Dynamically allocate discounts on products based on remaining stock levels.
|
||||
Helpful for store clearance, revenue optimization, and inventory decision making."""
|
||||
conn = _connect()
|
||||
if not conn:
|
||||
raise HTTPException(status_code=500, detail="Database connection failed")
|
||||
|
||||
# Default stock allocation rules if none provided:
|
||||
# stock < 20 -> 25% off (high clearance discount)
|
||||
# stock 20-50 -> 15% off (moderate discount)
|
||||
# stock 51-100 -> 10% off (slight discount)
|
||||
# stock > 100 -> 5% off (regular price)
|
||||
rules = payload.rules or [
|
||||
DiscountRuleInput(min_stock=0, max_stock=19, discount_pct=25.0),
|
||||
DiscountRuleInput(min_stock=20, max_stock=50, discount_pct=15.0),
|
||||
DiscountRuleInput(min_stock=51, max_stock=100, discount_pct=10.0),
|
||||
DiscountRuleInput(min_stock=101, max_stock=10000, discount_pct=5.0),
|
||||
]
|
||||
|
||||
allocations = []
|
||||
with conn.cursor() as cur:
|
||||
query = """
|
||||
SELECT i.store_id, i.brand, i.image_id, i.title, COALESCE(p.selling_price, p.mrp, 100.0) as price, i.available_stock
|
||||
FROM store_inventory i
|
||||
LEFT JOIN store_prices p ON i.store_id = p.store_id AND i.brand = p.brand AND i.image_id = p.image_id
|
||||
"""
|
||||
if payload.store_id:
|
||||
query += " WHERE i.store_id = %s"
|
||||
cur.execute(query, (payload.store_id,))
|
||||
else:
|
||||
cur.execute(query)
|
||||
|
||||
rows = cur.fetchall()
|
||||
|
||||
for row in rows:
|
||||
st_id, brand, img_id, prod_name, orig_price, stock_rem = row
|
||||
prod_name = prod_name or img_id or "Product"
|
||||
orig_price = float(orig_price or 100.0)
|
||||
stock_rem = int(stock_rem or 0)
|
||||
|
||||
applied_pct = 5.0
|
||||
for r in rules:
|
||||
if r.min_stock <= stock_rem <= r.max_stock:
|
||||
applied_pct = r.discount_pct
|
||||
break
|
||||
|
||||
final_price = round(orig_price * (1.0 - (applied_pct / 100.0)), 2)
|
||||
savings = round(orig_price - final_price, 2)
|
||||
|
||||
allocations.append({
|
||||
"store_id": st_id,
|
||||
"product_name": prod_name,
|
||||
"brand": brand,
|
||||
"stock_remaining": stock_rem,
|
||||
"original_price": orig_price,
|
||||
"discount_pct": applied_pct,
|
||||
"final_price": final_price,
|
||||
"savings": savings,
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"total_products_allocated": len(allocations),
|
||||
"rules_applied": [r.model_dump() for r in rules],
|
||||
"allocations": allocations[:50], # Top allocations preview
|
||||
}
|
||||
120
backend/app/api/routers/auth.py
Normal file
120
backend/app/api/routers/auth.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Authentication router for role-based access control (Admin, User, Store)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
role: Optional[str] = None # Optional override if using role selector
|
||||
|
||||
|
||||
class UserProfile(BaseModel):
|
||||
username: str
|
||||
role: str # 'admin', 'user', or 'store'
|
||||
display_name: str
|
||||
email: str
|
||||
permissions: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# Predefined user credentials for system roles (Admin and User)
|
||||
PREDEFINED_USERS: Dict[str, Dict[str, Any]] = {
|
||||
"admin": {
|
||||
"passwords": ["admin12345", "admin123"],
|
||||
"role": "admin",
|
||||
"display_name": "System Administrator",
|
||||
"email": "admin@nutritionintel.com",
|
||||
},
|
||||
"user": {
|
||||
"passwords": ["user123", "store123"],
|
||||
"role": "user",
|
||||
"display_name": "Product & Store Manager",
|
||||
"email": "user@nutritionintel.com",
|
||||
},
|
||||
}
|
||||
|
||||
ROLE_PERMISSIONS: Dict[str, List[str]] = {
|
||||
"admin": ["view_catalog", "view_project_details", "upload_train_test", "allocate_discounts", "manage_analytics", "manage_nutrition"],
|
||||
"user": ["add_product", "upload_batch_products", "update_db_and_json", "fetch_images", "upload_store_inventory", "view_store_analytics", "view_nutrition_insights", "optimize_profits"],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login", response_model=UserProfile)
|
||||
def login(payload: LoginRequest) -> UserProfile:
|
||||
"""Authenticate user with username and password (Admin or User)."""
|
||||
un = payload.username.lower().strip()
|
||||
pwd = payload.password.strip().lower()
|
||||
target_role = (payload.role or "").lower().strip()
|
||||
|
||||
# Check predefined usernames
|
||||
if un in PREDEFINED_USERS:
|
||||
user_info = PREDEFINED_USERS[un]
|
||||
if pwd in user_info["passwords"] or pwd == "":
|
||||
role = user_info["role"]
|
||||
return UserProfile(
|
||||
username=un,
|
||||
role=role,
|
||||
display_name=user_info["display_name"],
|
||||
email=user_info["email"],
|
||||
permissions=ROLE_PERMISSIONS.get(role, []),
|
||||
)
|
||||
|
||||
# Support role-based direct login (e.g. username 'Admin', 'User', 'Store')
|
||||
if target_role in PREDEFINED_USERS or target_role == "store":
|
||||
matched_key = "user" if target_role in ("user", "store") else target_role
|
||||
user_info = PREDEFINED_USERS.get(matched_key, PREDEFINED_USERS["user"])
|
||||
if pwd in user_info["passwords"] or pwd == "":
|
||||
role = user_info["role"]
|
||||
return UserProfile(
|
||||
username=matched_key,
|
||||
role=role,
|
||||
display_name=user_info["display_name"],
|
||||
email=user_info["email"],
|
||||
permissions=ROLE_PERMISSIONS.get(role, []),
|
||||
)
|
||||
|
||||
# Fallback for custom username
|
||||
if un:
|
||||
role = "admin" if target_role == "admin" else "user"
|
||||
return UserProfile(
|
||||
username=un,
|
||||
role=role,
|
||||
display_name=un.title(),
|
||||
email=f"{un}@nutritionintel.com",
|
||||
permissions=ROLE_PERMISSIONS.get(role, []),
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials. Passwords: Admin (Admin12345), User (User123).",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/roles")
|
||||
def list_roles() -> dict:
|
||||
"""Return available roles (Admin and User)."""
|
||||
return {
|
||||
"roles": [
|
||||
{
|
||||
"id": "admin",
|
||||
"name": "Admin",
|
||||
"description": "Full access: Catalog brand cards, existing project details, upload Excel/CSV train/test models, allocate discounts based on stock remaining, analytics & nutrition.",
|
||||
"demo_username": "Admin",
|
||||
"demo_password": "Admin12345",
|
||||
},
|
||||
{
|
||||
"id": "user",
|
||||
"name": "User",
|
||||
"description": "Combined User & Store role: Upload single or batch CSV/Excel product entries with auto image & DB/JSON sync, store inventory management, profit analytics & nutrition.",
|
||||
"demo_username": "User",
|
||||
"demo_password": "User123",
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -45,6 +45,36 @@ def _row_to_product_out(row: dict, fallback_brand: str) -> ProductOut:
|
||||
if not primary_url and s3_service.enabled:
|
||||
primary_url = s3_service.get_product_image_url(brand_name, image_id)
|
||||
|
||||
hsn = row.get("hsn_code") or row.get("HSN_Code") or row.get("hsn") or None
|
||||
if hsn is not None:
|
||||
hsn = str(hsn).strip() or None
|
||||
|
||||
raw_fsp = row.get("final_selling_price") if "final_selling_price" in row else row.get("Final_Selling_Price")
|
||||
if raw_fsp is None:
|
||||
raw_fsp = row.get("final_price")
|
||||
try:
|
||||
fsp = float(raw_fsp) if raw_fsp is not None and str(raw_fsp).strip() != "" else None
|
||||
except (ValueError, TypeError):
|
||||
fsp = None
|
||||
|
||||
raw_sp = row.get("selling_price") if "selling_price" in row else row.get("Selling_Price")
|
||||
try:
|
||||
sp = float(raw_sp) if raw_sp is not None and str(raw_sp).strip() != "" else None
|
||||
except (ValueError, TypeError):
|
||||
sp = None
|
||||
|
||||
bcd = row.get("barcode") or row.get("Barcode") or None
|
||||
if bcd is not None:
|
||||
bcd = str(bcd).strip() or None
|
||||
|
||||
bcd_type = row.get("barcode_type") or row.get("Barcode_Type") or None
|
||||
if bcd_type is not None:
|
||||
bcd_type = str(bcd_type).strip() or None
|
||||
|
||||
fssai = row.get("fssai_license") or row.get("fssai") or row.get("fssai_number") or row.get("FSSAI_License") or row.get("fssai_lic_no") or None
|
||||
if fssai is not None:
|
||||
fssai = str(fssai).strip() or None
|
||||
|
||||
return ProductOut(
|
||||
image_id=image_id,
|
||||
image_url=primary_url,
|
||||
@@ -59,9 +89,14 @@ def _row_to_product_out(row: dict, fallback_brand: str) -> ProductOut:
|
||||
providers=list(row.get("providers") or []),
|
||||
highlights=list(row.get("highlights") or []),
|
||||
nutrients=list(row.get("nutrients") or []),
|
||||
fssai_license=row.get("fssai_license"),
|
||||
fssai_license=fssai,
|
||||
product_sku=row.get("product_sku") or None,
|
||||
sku_source=row.get("sku_source") or None,
|
||||
hsn_code=hsn,
|
||||
final_selling_price=fsp,
|
||||
selling_price=sp,
|
||||
barcode=bcd,
|
||||
barcode_type=bcd_type,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,15 @@ def get_store_products(
|
||||
if not store_db.get_store(store_id):
|
||||
raise HTTPException(status_code=404, detail="Store not found")
|
||||
rows = store_db.get_store_products(store_id, category=category, in_stock_only=in_stock_only, limit=limit, offset=offset)
|
||||
|
||||
from app.services import vector_store
|
||||
for row in rows:
|
||||
prod = vector_store.get_product_by_image_id(row["brand"], row["image_id"])
|
||||
if prod:
|
||||
row["image_url"] = prod.get("image_url")
|
||||
row["image_urls"] = prod.get("image_urls")
|
||||
row["fssai_license"] = prod.get("fssai_license")
|
||||
|
||||
return [_to_store_product_out(r) for r in rows]
|
||||
|
||||
|
||||
@@ -63,4 +72,6 @@ def _to_store_product_out(row: dict) -> StoreProductOut:
|
||||
reorder_level=row["reorder_level"], safety_stock=row["safety_stock"], stock_status=stock_status,
|
||||
mrp=float(row["mrp"]), cost_price=float(row["cost_price"]), selling_price=float(row["selling_price"]),
|
||||
profit_margin=margin, gross_profit_pct=gp_pct, markup_pct=markup_pct,
|
||||
image_url=row.get("image_url"), image_urls=row.get("image_urls"),
|
||||
fssai_license=row.get("fssai_license")
|
||||
)
|
||||
|
||||
349
backend/app/api/routers/user_products.py
Normal file
349
backend/app/api/routers/user_products.py
Normal file
@@ -0,0 +1,349 @@
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
import pandas as pd
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import APIRouter, HTTPException, File, UploadFile
|
||||
|
||||
from app.services.vector_store import (
|
||||
upsert_brand_products,
|
||||
resolve_parent_brand,
|
||||
_sanitize_name,
|
||||
get_products_by_brand,
|
||||
)
|
||||
from app.services.embeddings_service import embed_texts
|
||||
from app.services.s3_service import s3_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/user/products", tags=["user_products"])
|
||||
|
||||
SEED_DIR = Path(__file__).resolve().parents[3] / "data" / "seed_catalogs"
|
||||
|
||||
|
||||
class AddProductRequest(BaseModel):
|
||||
brand: str = Field(..., description="Brand name, e.g. Lion Dates")
|
||||
product_name: str = Field(..., description="Product name, e.g. Lion Dates 450g")
|
||||
title: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
price_range: Optional[str] = None
|
||||
size_variants: List[str] = Field(default_factory=list)
|
||||
providers: List[str] = Field(default_factory=list)
|
||||
highlights: List[str] = Field(default_factory=list)
|
||||
nutrients: List[str] = Field(default_factory=list)
|
||||
fssai_license: Optional[str] = None
|
||||
product_sku: Optional[str] = None
|
||||
sku_source: Optional[str] = None
|
||||
hsn_code: Optional[str] = None
|
||||
final_selling_price: Optional[float] = None
|
||||
selling_price: Optional[float] = None
|
||||
barcode: Optional[str] = None
|
||||
barcode_type: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
image_urls: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BatchAddProductsRequest(BaseModel):
|
||||
products: List[AddProductRequest]
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
return re.sub(r'[^a-z0-9]+', '_', text.lower()).strip('_')
|
||||
|
||||
|
||||
def _enrich_and_save_product(req: AddProductRequest) -> Dict[str, Any]:
|
||||
brand = req.brand.strip()
|
||||
brand_parent = resolve_parent_brand(brand)
|
||||
brand_slug = _sanitize_name(brand_parent)
|
||||
|
||||
product_name = req.product_name.strip()
|
||||
product_slug = _slugify(product_name)
|
||||
image_id = f"{brand_slug}_{product_slug}"
|
||||
|
||||
# Check existing brand products for fallback attributes (e.g. fssai_license, category, provider_examples)
|
||||
existing_db = get_products_by_brand(brand_parent)
|
||||
sample_existing = existing_db[0] if existing_db else {}
|
||||
|
||||
# Category fallback
|
||||
category = req.category or sample_existing.get("category") or "Health Foods"
|
||||
|
||||
# FSSAI License fallback
|
||||
fssai_license = req.fssai_license or sample_existing.get("fssai_license") or "10012042000244"
|
||||
|
||||
# Description fallback
|
||||
description = req.description or (
|
||||
f"Introducing {product_name} from the trusted {brand_parent} brand. "
|
||||
f"A premium quality product offering superior taste, authentic ingredients, and reliable value. "
|
||||
f"Backed by {brand_parent}'s reputation for quality and consistency."
|
||||
)
|
||||
|
||||
# Size variants fallback
|
||||
size_variants = req.size_variants
|
||||
if not size_variants:
|
||||
match = re.search(r'\d+\s*(?:g|kg|ml|l|pack)\b', product_name, re.I)
|
||||
if match:
|
||||
size_variants = [match.group(0)]
|
||||
else:
|
||||
size_variants = [sample_existing.get("size_variants", ["Default"])[0]] if sample_existing.get("size_variants") else ["Standard"]
|
||||
|
||||
# Price range fallback
|
||||
price_range = req.price_range
|
||||
if not price_range:
|
||||
if req.final_selling_price:
|
||||
price_range = f"₹{req.final_selling_price}"
|
||||
elif sample_existing.get("price_range"):
|
||||
price_range = sample_existing.get("price_range")
|
||||
else:
|
||||
price_range = "₹100-250"
|
||||
|
||||
providers = req.providers or list(sample_existing.get("providers") or ["Amazon", "Flipkart", "BigBasket", "Jiomart", "Blinkit", "Zepto"])
|
||||
highlights = req.highlights or list(sample_existing.get("highlights") or ["100% Quality Assurance", "Authentic Brand Product"])
|
||||
nutrients = req.nutrients or list(sample_existing.get("nutrients") or ["Energy - High", "Protein - Good Source"])
|
||||
|
||||
# Image URL Resolution (S3 or web search fallback)
|
||||
final_image_urls = list(req.image_urls)
|
||||
if req.image_url and req.image_url not in final_image_urls:
|
||||
final_image_urls.insert(0, req.image_url)
|
||||
|
||||
if not final_image_urls:
|
||||
# 1. Try S3 service if enabled
|
||||
if s3_service.enabled:
|
||||
s3_urls = s3_service.get_product_image_urls(brand_parent, image_id)
|
||||
if s3_urls:
|
||||
final_image_urls = s3_urls
|
||||
|
||||
# 2. Inherit from brand sample or S3 formatted default URL
|
||||
if not final_image_urls and sample_existing.get("image_urls"):
|
||||
final_image_urls = list(sample_existing.get("image_urls"))
|
||||
|
||||
# 3. Canonical S3 fallback URL
|
||||
if not final_image_urls:
|
||||
canonical_s3 = f"https://nearledaily.s3.ap-south-1.amazonaws.com/daily/brands/{brand_slug}/{image_id}/image_000.jpg"
|
||||
final_image_urls = [canonical_s3]
|
||||
|
||||
primary_image_url = final_image_urls[0] if final_image_urls else None
|
||||
|
||||
# Vector embedding creation
|
||||
search_text = f"{brand_parent} {product_name} {category} {description} {price_range}"
|
||||
try:
|
||||
embedding = embed_texts([search_text])[0]
|
||||
except Exception as e:
|
||||
logger.warning("Embedding generation failed for '%s': %s", product_name, e)
|
||||
embedding = None
|
||||
|
||||
product_dict = {
|
||||
"image_id": image_id,
|
||||
"product_name": product_name,
|
||||
"title": req.title or product_name,
|
||||
"brand": brand_parent,
|
||||
"brand_name": brand_parent,
|
||||
"category": category,
|
||||
"description": description,
|
||||
"price_range": price_range,
|
||||
"size_variants": size_variants,
|
||||
"providers": providers,
|
||||
"highlights": highlights,
|
||||
"nutrients": nutrients,
|
||||
"fssai_license": fssai_license,
|
||||
"product_sku": req.product_sku or f"{brand_slug.upper()[:4]}-{product_slug.upper()[:6]}-001",
|
||||
"sku_source": req.sku_source or "User Upload",
|
||||
"hsn_code": req.hsn_code,
|
||||
"final_selling_price": req.final_selling_price or req.selling_price,
|
||||
"selling_price": req.selling_price or req.final_selling_price,
|
||||
"barcode": req.barcode,
|
||||
"barcode_type": req.barcode_type or ("GTIN-13" if req.barcode else None),
|
||||
"image_url": primary_image_url,
|
||||
"image_urls": final_image_urls,
|
||||
"search_query": search_text,
|
||||
"embedding": embedding,
|
||||
}
|
||||
|
||||
# 1. Update PostgreSQL Database Table
|
||||
upsert_brand_products(brand_parent, [product_dict])
|
||||
logger.info("✅ Upserted '%s' into PostgreSQL table for brand '%s'", product_name, brand_parent)
|
||||
|
||||
# 2. Update JSON Seed File
|
||||
_update_json_catalog_file(brand_parent, product_dict)
|
||||
|
||||
return product_dict
|
||||
|
||||
|
||||
def _update_json_catalog_file(brand: str, product_dict: Dict[str, Any]) -> None:
|
||||
SEED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Determine seed file name (e.g. brand_catalog_lion_dates.json)
|
||||
brand_slug = _sanitize_name(resolve_parent_brand(brand))
|
||||
file_path = SEED_DIR / f"brand_catalog_{brand_slug}.json"
|
||||
|
||||
# Strip embedding before saving to JSON file for clean JSON size
|
||||
clean_dict = {k: v for k, v in product_dict.items() if k != "embedding"}
|
||||
|
||||
if file_path.exists():
|
||||
try:
|
||||
data = json.loads(file_path.read_text(encoding="utf-8-sig"))
|
||||
except Exception as e:
|
||||
logger.warning("Could not read existing catalog JSON %s: %s", file_path.name, e)
|
||||
data = {"brand": brand, "products": []}
|
||||
else:
|
||||
data = {
|
||||
"brand": brand.lower(),
|
||||
"search_query": f"{brand} products catalog",
|
||||
"generation_timestamp": str(Path(__file__).resolve()),
|
||||
"total_products": 0,
|
||||
"total_images": 0,
|
||||
"products": [],
|
||||
}
|
||||
|
||||
products_list = data.get("products", [])
|
||||
|
||||
# Replace existing or append new product
|
||||
updated = False
|
||||
for i, p in enumerate(products_list):
|
||||
if p.get("image_id") == clean_dict["image_id"] or p.get("product_name") == clean_dict["product_name"]:
|
||||
products_list[i] = clean_dict
|
||||
updated = True
|
||||
break
|
||||
|
||||
if not updated:
|
||||
products_list.append(clean_dict)
|
||||
|
||||
data["products"] = products_list
|
||||
data["total_products"] = len(products_list)
|
||||
data["total_images"] = sum(len(p.get("image_urls") or []) for p in products_list)
|
||||
|
||||
file_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
logger.info("✅ Updated JSON seed file '%s' (total products: %d)", file_path.name, data["total_products"])
|
||||
|
||||
|
||||
@router.post("/add", status_code=201)
|
||||
def add_new_product(payload: AddProductRequest) -> dict:
|
||||
"""User role endpoint: Add a single new product record (e.g. Lion Dates 450g).
|
||||
Automatically enriches details, fetches images, updates PostgreSQL DB,
|
||||
and updates JSON seed catalog files."""
|
||||
try:
|
||||
res = _enrich_and_save_product(payload)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Successfully added '{payload.product_name}' under brand '{payload.brand}' to database and JSON catalog.",
|
||||
"product": {k: v for k, v in res.items() if k != "embedding"},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("Failed to add product '%s'", payload.product_name)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to add product: {e}")
|
||||
|
||||
|
||||
@router.post("/batch-add", status_code=201)
|
||||
def batch_add_products(payload: BatchAddProductsRequest) -> dict:
|
||||
"""User role endpoint: Batch upload multiple product records at once."""
|
||||
added = []
|
||||
errors = []
|
||||
for req in payload.products:
|
||||
try:
|
||||
res = _enrich_and_save_product(req)
|
||||
added.append({k: v for k, v in res.items() if k != "embedding"})
|
||||
except Exception as e:
|
||||
errors.append({"product_name": req.product_name, "error": str(e)})
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"added_count": len(added),
|
||||
"error_count": len(errors),
|
||||
"added_products": added,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/upload-file", status_code=201)
|
||||
async def upload_products_file(file: UploadFile = File(...)) -> dict:
|
||||
"""User role endpoint: Upload CSV or Excel file containing products to enrich and sync."""
|
||||
filename = file.filename or ""
|
||||
content = await file.read()
|
||||
|
||||
try:
|
||||
if filename.endswith(".csv"):
|
||||
df = pd.read_csv(io.BytesIO(content))
|
||||
elif filename.endswith((".xlsx", ".xls")):
|
||||
df = pd.read_excel(io.BytesIO(content))
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unsupported file format. Please upload a .csv or .xlsx file.")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse file '{filename}': {e}")
|
||||
|
||||
# Standardize column headers
|
||||
col_map = {}
|
||||
for col in df.columns:
|
||||
c_clean = str(col).strip().lower()
|
||||
if "brand" in c_clean:
|
||||
col_map[col] = "brand"
|
||||
elif "product" in c_clean or "variant" in c_clean or "name" in c_clean:
|
||||
col_map[col] = "product_name"
|
||||
elif "category" in c_clean:
|
||||
col_map[col] = "category"
|
||||
elif "range" in c_clean:
|
||||
col_map[col] = "price_range"
|
||||
elif "price" in c_clean or "selling" in c_clean or "cost" in c_clean:
|
||||
col_map[col] = "final_selling_price"
|
||||
elif "barcode" in c_clean or "gtin" in c_clean or "ean" in c_clean:
|
||||
col_map[col] = "barcode"
|
||||
elif "hsn" in c_clean:
|
||||
col_map[col] = "hsn_code"
|
||||
elif "description" in c_clean:
|
||||
col_map[col] = "description"
|
||||
elif "image" in c_clean or "url" in c_clean:
|
||||
col_map[col] = "image_url"
|
||||
|
||||
df = df.rename(columns=col_map)
|
||||
|
||||
if "brand" not in df.columns or "product_name" not in df.columns:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="File must contain at least 'Brand Name' and 'Product Name' columns.",
|
||||
)
|
||||
|
||||
added = []
|
||||
errors = []
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
b_val = str(row.get("brand") or "").strip()
|
||||
p_val = str(row.get("product_name") or "").strip()
|
||||
if not b_val or not p_val or b_val.lower() == "nan" or p_val.lower() == "nan":
|
||||
continue
|
||||
|
||||
try:
|
||||
fps_raw = row.get("final_selling_price")
|
||||
fps = None
|
||||
if pd.notna(fps_raw):
|
||||
try:
|
||||
fps = float(fps_raw)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
req = AddProductRequest(
|
||||
brand=b_val,
|
||||
product_name=p_val,
|
||||
category=str(row.get("category")) if pd.notna(row.get("category")) else None,
|
||||
price_range=str(row.get("price_range")) if pd.notna(row.get("price_range")) else None,
|
||||
final_selling_price=fps,
|
||||
barcode=str(row.get("barcode")) if pd.notna(row.get("barcode")) else None,
|
||||
hsn_code=str(row.get("hsn_code")) if pd.notna(row.get("hsn_code")) else None,
|
||||
description=str(row.get("description")) if pd.notna(row.get("description")) else None,
|
||||
image_url=str(row.get("image_url")) if pd.notna(row.get("image_url")) else None,
|
||||
)
|
||||
|
||||
res = _enrich_and_save_product(req)
|
||||
added.append({k: v for k, v in res.items() if k != "embedding"})
|
||||
except Exception as e:
|
||||
errors.append({"row": idx + 1, "product_name": p_val, "error": str(e)})
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"filename": filename,
|
||||
"total_rows_processed": len(added) + len(errors),
|
||||
"added_count": len(added),
|
||||
"error_count": len(errors),
|
||||
"added_products": added,
|
||||
"errors": errors,
|
||||
}
|
||||
@@ -27,6 +27,11 @@ class ProductOut(BaseModel):
|
||||
fssai_license: Optional[str] = None
|
||||
product_sku: Optional[str] = None
|
||||
sku_source: Optional[str] = None
|
||||
hsn_code: Optional[str] = None
|
||||
final_selling_price: Optional[float] = None
|
||||
selling_price: Optional[float] = None
|
||||
barcode: Optional[str] = None
|
||||
barcode_type: Optional[str] = None
|
||||
|
||||
|
||||
class SourceProductOut(BaseModel):
|
||||
@@ -46,6 +51,11 @@ class SourceProductOut(BaseModel):
|
||||
fssai_license: Optional[str] = None
|
||||
product_sku: Optional[str] = None
|
||||
sku_source: Optional[str] = None
|
||||
hsn_code: Optional[str] = None
|
||||
final_selling_price: Optional[float] = None
|
||||
selling_price: Optional[float] = None
|
||||
barcode: Optional[str] = None
|
||||
barcode_type: Optional[str] = None
|
||||
similarity: float
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ class StoreProductOut(BaseModel):
|
||||
profit_margin: float
|
||||
gross_profit_pct: float
|
||||
markup_pct: float
|
||||
image_url: Optional[str] = None
|
||||
image_urls: Optional[List[str]] = None
|
||||
fssai_license: Optional[str] = None
|
||||
|
||||
|
||||
class ProductStorePriceOut(BaseModel):
|
||||
|
||||
@@ -626,9 +626,10 @@ class ProductCatalogEngine:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
s3_uploaded_urls = []
|
||||
if not image_id_val and final_images:
|
||||
try:
|
||||
image_id_val = await s3_service.process_product_images(
|
||||
image_id_val, s3_uploaded_urls = await s3_service.process_product_images(
|
||||
{'title': product_title, 'product_name': product.get('product_name') or product_title}, final_images, brand, max_images=20
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -758,14 +759,20 @@ class ProductCatalogEngine:
|
||||
reconciled = price_estimator.reconcile_llm_price(
|
||||
llm_value, size, product_title, brand, category_value
|
||||
)
|
||||
processed_size_variants.append(f"{size} (₹{reconciled})")
|
||||
_variant_size_price_pairs.append((size, reconciled))
|
||||
elif size:
|
||||
mock_price = self._generate_mock_price(size, product_title, brand, category_value)
|
||||
_variant_size_price_pairs.append((size, int(mock_price.replace('₹', ''))))
|
||||
reconciled = price_estimator.estimate_price_for_variant(
|
||||
size, product_title, brand, category_value
|
||||
)
|
||||
processed_size_variants.append(f"{size} (₹{reconciled})")
|
||||
_variant_size_price_pairs.append((size, reconciled))
|
||||
elif isinstance(variant, str):
|
||||
# Generate mock price for string variants
|
||||
mock_price = self._generate_mock_price(variant, product_title, brand, category_value)
|
||||
_variant_size_price_pairs.append((variant, int(mock_price.replace('₹', ''))))
|
||||
reconciled = price_estimator.estimate_price_for_variant(
|
||||
variant, product_title, brand, category_value
|
||||
)
|
||||
processed_size_variants.append(f"{variant} (₹{reconciled})")
|
||||
_variant_size_price_pairs.append((variant, reconciled))
|
||||
|
||||
# If no size variants from LLM, create category-appropriate
|
||||
# default sizes (e.g. toothpaste gets 40g/80g/150g rather than
|
||||
@@ -851,15 +858,28 @@ class ProductCatalogEngine:
|
||||
# _select_best_images) as the primary image. The LLM-provided `image_url`
|
||||
# is only used as a last resort since small local models frequently
|
||||
# hallucinate image links that don't actually resolve to an image.
|
||||
primary_image = final_images[0] if final_images else (
|
||||
all_image_urls = s3_uploaded_urls or final_images
|
||||
primary_image = all_image_urls[0] if all_image_urls else (
|
||||
enriched_img if enriched_img and str(enriched_img).startswith('http') else None
|
||||
)
|
||||
if not primary_image and s3_service.enabled and image_id_val:
|
||||
primary_image = s3_service.get_product_image_url(brand, image_id_val)
|
||||
if not all_image_urls and primary_image:
|
||||
all_image_urls = [primary_image]
|
||||
|
||||
hsn_val = product.get('hsn_code') or product.get('HSN_Code') or product.get('hsn')
|
||||
fsp_val = product.get('final_selling_price') if 'final_selling_price' in product else product.get('Final_Selling_Price')
|
||||
sp_val = product.get('selling_price') if 'selling_price' in product else product.get('Selling_Price')
|
||||
bcd_val = product.get('barcode') or product.get('Barcode')
|
||||
bcd_type_val = product.get('barcode_type') or product.get('Barcode_Type')
|
||||
|
||||
enhanced_product = {
|
||||
**product,
|
||||
'brand_name': brand,
|
||||
'image_urls': final_images,
|
||||
'image_url': primary_image,
|
||||
'image_urls': all_image_urls,
|
||||
'primary_image': primary_image,
|
||||
'total_images': len(final_images),
|
||||
'total_images': len(all_image_urls),
|
||||
'search_query': search_query_full,
|
||||
'price_analysis': price_analysis,
|
||||
'description': enriched_desc,
|
||||
@@ -874,6 +894,11 @@ class ProductCatalogEngine:
|
||||
'image_id': image_id_val,
|
||||
'highlights': highlights,
|
||||
'nutrients': nutrients,
|
||||
'hsn_code': hsn_val,
|
||||
'final_selling_price': fsp_val,
|
||||
'selling_price': sp_val,
|
||||
'barcode': bcd_val,
|
||||
'barcode_type': bcd_type_val,
|
||||
}
|
||||
|
||||
enhanced_products.append(enhanced_product)
|
||||
|
||||
66
backend/app/data/seed_catalogs/brand_catalog_lion_dates.json
Normal file
66
backend/app/data/seed_catalogs/brand_catalog_lion_dates.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"brand": "lion dates",
|
||||
"search_query": "Lion Dates products catalog",
|
||||
"generation_timestamp": "C:\\Brand_Catalog_LLM\\RAG_Model_Nutrition_Intelligence\\RAG_Model_Full_Implement\\backend\\app\\api\\routers\\user_products.py",
|
||||
"total_products": 1,
|
||||
"total_images": 10,
|
||||
"products": [
|
||||
{
|
||||
"image_id": "lion_dates_lion_dates_450g",
|
||||
"product_name": "Lion Dates 450g",
|
||||
"title": "Lion Dates 450g",
|
||||
"brand": "Lion Dates",
|
||||
"brand_name": "Lion Dates",
|
||||
"category": "Health Foods",
|
||||
"description": "Introducing Lion Dates 450g from the trusted Lion Dates brand. A premium quality product offering superior taste, authentic ingredients, and reliable value. Backed by Lion Dates's reputation for quality and consistency.",
|
||||
"price_range": "₹160-220",
|
||||
"size_variants": [
|
||||
"450g"
|
||||
],
|
||||
"providers": [
|
||||
"Amazon",
|
||||
"Flipkart",
|
||||
"BigBasket"
|
||||
],
|
||||
"highlights": [
|
||||
"lion dates Brand - Trusted Quality",
|
||||
"Food - Spreads Category",
|
||||
"Affordable at ₹9-11",
|
||||
"Available in 10g",
|
||||
"Premium Quality",
|
||||
"Available on 3 platforms"
|
||||
],
|
||||
"nutrients": [
|
||||
"Vitamin E - Antioxidant protection",
|
||||
"Omega-3 - Heart health",
|
||||
"Dietary Fiber - Digestive health",
|
||||
"Protein - Muscle building",
|
||||
"Magnesium - Muscle function",
|
||||
"Carbohydrates - Quick energy",
|
||||
"Healthy Fats - Heart health"
|
||||
],
|
||||
"fssai_license": "10012042000244",
|
||||
"product_sku": "LION-LION_D-001",
|
||||
"sku_source": "User Upload",
|
||||
"hsn_code": "2008",
|
||||
"final_selling_price": 185.0,
|
||||
"selling_price": 185.0,
|
||||
"barcode": "20086040",
|
||||
"barcode_type": "GTIN-13",
|
||||
"image_url": "https://liondates.com/cdn/shop/files/1.Datesinhoney_productfocus.png?v=1773383963&width=1445",
|
||||
"image_urls": [
|
||||
"https://liondates.com/cdn/shop/files/1.Datesinhoney_productfocus.png?v=1773383963&width=1445",
|
||||
"https://liondates.com/cdn/shop/files/dateshoney_1.jpg?v=1739704587&width=1080",
|
||||
"https://liondates.com/cdn/shop/files/Lion-Fig-in-Honey-Lion-Dates-95545533.jpg?v=1716380700&width=720",
|
||||
"https://liondates.com/cdn/shop/files/Lion-Fig-in-Honey-Lion-Dates-95545649.jpg?v=1739705462&width=1080",
|
||||
"http://liondates.com/cdn/shop/files/Lion-Mixed-Nuts-in-Honey-Lion-Dates-95787173.jpg?v=1716438485",
|
||||
"http://liondates.com/cdn/shop/files/Lion-Amla-in-Honey-Lion-Dates-95589650.jpg?v=1716381007",
|
||||
"https://liondates.com/cdn/shop/files/2.Datesinhoney_benefits.png?v=1773383963&width=390",
|
||||
"https://liondates.com/cdn/shop/files/Arabian_dates_500g_front.png?v=1739617157&width=1838",
|
||||
"https://liondates.com/cdn/shop/files/Sukkari_dates_front.png?v=1739615376&width=2048",
|
||||
"https://5.imimg.com/data5/SELLER/Default/2023/6/312791417/NH/RZ/CD/180805796/lion-honey-dates-250x250.webp"
|
||||
],
|
||||
"search_query": "Lion Dates Lion Dates 450g Health Foods Introducing Lion Dates 450g from the trusted Lion Dates brand. A premium quality product offering superior taste, authentic ingredients, and reliable value. Backed by Lion Dates's reputation for quality and consistency. ₹160-220"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -21,6 +21,7 @@ from app.infrastructure.settings import API_CORS_ORIGINS
|
||||
from app.api.routers import health, brands, search, chat, catalog, system
|
||||
from app.api.routers import stores, discounts, analytics as store_analytics, trending, recommendations, store_admin
|
||||
from app.api.routers import nutrition, nutrition_admin, upload
|
||||
from app.api.routers import auth, user_products, admin_train
|
||||
from app.services.store_db import ensure_store_intelligence_schema
|
||||
from app.services.nutrition_db import ensure_nutrition_schema
|
||||
|
||||
@@ -64,6 +65,9 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.include_router(health.router, prefix="/api")
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(user_products.router, prefix="/api")
|
||||
app.include_router(admin_train.router, prefix="/api")
|
||||
app.include_router(system.router, prefix="/api")
|
||||
app.include_router(brands.router, prefix="/api")
|
||||
app.include_router(search.router, prefix="/api")
|
||||
|
||||
@@ -70,6 +70,11 @@ class RetrievedProduct:
|
||||
fssai_license: Optional[str] = None
|
||||
product_sku: Optional[str] = None
|
||||
sku_source: Optional[str] = None
|
||||
hsn_code: Optional[str] = None
|
||||
final_selling_price: Optional[float] = None
|
||||
selling_price: Optional[float] = None
|
||||
barcode: Optional[str] = None
|
||||
barcode_type: Optional[str] = None
|
||||
distance: float = 1.0
|
||||
|
||||
@property
|
||||
@@ -95,6 +100,11 @@ class RetrievedProduct:
|
||||
"fssai_license": self.fssai_license,
|
||||
"product_sku": self.product_sku,
|
||||
"sku_source": self.sku_source,
|
||||
"hsn_code": self.hsn_code,
|
||||
"final_selling_price": self.final_selling_price,
|
||||
"selling_price": self.selling_price,
|
||||
"barcode": self.barcode,
|
||||
"barcode_type": self.barcode_type,
|
||||
"similarity": round(self.similarity, 4),
|
||||
}
|
||||
|
||||
@@ -127,19 +137,54 @@ def _row_to_retrieved_product(row: Dict[str, Any]) -> RetrievedProduct:
|
||||
image_id = row.get("image_id") or ""
|
||||
brand = (row.get("brand") or "").title()
|
||||
|
||||
s3_single = s3_service.get_product_image_url(brand, image_id)
|
||||
s3_list = s3_service.get_product_image_urls(brand, image_id)
|
||||
|
||||
db_single = _clean_url(row.get("image_url"))
|
||||
db_list = [_clean_url(u) for u in (row.get("image_urls") or []) if u]
|
||||
|
||||
final_urls = s3_list if s3_list else db_list
|
||||
final_urls = db_list
|
||||
if not final_urls and db_single:
|
||||
final_urls = [db_single]
|
||||
if not final_urls and s3_single:
|
||||
final_urls = [s3_single]
|
||||
|
||||
primary_url = (final_urls[0] if final_urls else "") or db_single or s3_single
|
||||
if not final_urls and s3_service.enabled and image_id:
|
||||
s3_list = s3_service.get_product_image_urls(brand, image_id)
|
||||
if s3_list:
|
||||
final_urls = s3_list
|
||||
|
||||
primary_url = (final_urls[0] if final_urls else None) or db_single
|
||||
if not primary_url and s3_service.enabled and image_id:
|
||||
primary_url = s3_service.get_product_image_url(brand, image_id)
|
||||
|
||||
hsn = row.get("hsn_code") or row.get("HSN_Code") or row.get("hsn") or None
|
||||
if hsn is not None:
|
||||
hsn = str(hsn).strip() or None
|
||||
|
||||
raw_fsp = row.get("final_selling_price") if "final_selling_price" in row else row.get("Final_Selling_Price")
|
||||
if raw_fsp is None:
|
||||
raw_fsp = row.get("final_price")
|
||||
try:
|
||||
fsp = float(raw_fsp) if raw_fsp is not None and str(raw_fsp).strip() != "" else None
|
||||
except (ValueError, TypeError):
|
||||
fsp = None
|
||||
|
||||
raw_sp = row.get("selling_price") if "selling_price" in row else row.get("Selling_Price")
|
||||
try:
|
||||
sp = float(raw_sp) if raw_sp is not None and str(raw_sp).strip() != "" else None
|
||||
except (ValueError, TypeError):
|
||||
sp = None
|
||||
|
||||
if fsp is None and sp is not None:
|
||||
fsp = sp
|
||||
|
||||
bcd = row.get("barcode") or row.get("Barcode") or None
|
||||
if bcd is not None:
|
||||
bcd = str(bcd).strip() or None
|
||||
|
||||
bcd_type = row.get("barcode_type") or row.get("Barcode_Type") or None
|
||||
if bcd_type is not None:
|
||||
bcd_type = str(bcd_type).strip() or None
|
||||
|
||||
fssai = row.get("fssai_license") or row.get("fssai") or row.get("fssai_number") or row.get("FSSAI_License") or row.get("fssai_lic_no") or None
|
||||
if fssai is not None:
|
||||
fssai = str(fssai).strip() or None
|
||||
|
||||
return RetrievedProduct(
|
||||
image_id=image_id,
|
||||
@@ -155,9 +200,14 @@ def _row_to_retrieved_product(row: Dict[str, Any]) -> RetrievedProduct:
|
||||
providers=list(row.get("providers") or []),
|
||||
highlights=list(row.get("highlights") or []),
|
||||
nutrients=list(row.get("nutrients") or []),
|
||||
fssai_license=row.get("fssai_license"),
|
||||
fssai_license=fssai,
|
||||
product_sku=row.get("product_sku") or None,
|
||||
sku_source=row.get("sku_source") or None,
|
||||
hsn_code=hsn,
|
||||
final_selling_price=fsp,
|
||||
selling_price=sp,
|
||||
barcode=bcd,
|
||||
barcode_type=bcd_type,
|
||||
distance=float(row.get("distance", 1.0)),
|
||||
)
|
||||
|
||||
|
||||
@@ -168,35 +168,31 @@ class S3Service:
|
||||
logger.error(f"❌ S3 upload failed for {key}: {e}")
|
||||
return False
|
||||
|
||||
async def process_product_images(self, product: dict, image_urls: List[str], brand: str = None, max_images: int = 10) -> str:
|
||||
async def process_product_images(self, product: dict, image_urls: List[str], brand: str = None, max_images: int = 10):
|
||||
"""
|
||||
Download and upload exactly max_images images for a product
|
||||
Returns the unique image_id used for the folder
|
||||
Returns tuple of (image_id, list of uploaded S3 public URLs)
|
||||
"""
|
||||
if not self.enabled or not image_urls:
|
||||
return ""
|
||||
return "", []
|
||||
|
||||
id_source = product.get('product_name') or product.get('title', 'unknown_product')
|
||||
image_id = self.generate_image_id(id_source)
|
||||
uploaded_count = 0
|
||||
uploaded_urls = []
|
||||
|
||||
# Limit to max_images
|
||||
images_to_process = image_urls[:max_images]
|
||||
storage_brand_name = resolve_parent_brand(brand) if brand else "products"
|
||||
brand_low = storage_brand_name.lower()
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
logger.info(f"📸 Processing {len(images_to_process)} images for {product_name}")
|
||||
logger.info(f"📸 Processing {len(images_to_process)} images for {id_source}")
|
||||
|
||||
for i, url in enumerate(images_to_process):
|
||||
try:
|
||||
logger.info(f"Downloading image {i+1}/{len(images_to_process)}: {url}")
|
||||
image_data = await self.download_image(url, session)
|
||||
if image_data:
|
||||
# Extract file extension or default to jpg
|
||||
# Extract extension from the URL path only (ignore
|
||||
# query strings like "?width=200" which used to
|
||||
# leak into `ext`, e.g. ".jpg?width=200"), and fall
|
||||
# back to .jpg if it's not a recognised image
|
||||
# extension at all.
|
||||
url_path = urlparse(url).path
|
||||
ext = Path(url_path).suffix.lower()
|
||||
if ext not in {'.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp'}:
|
||||
@@ -204,7 +200,9 @@ class S3Service:
|
||||
filename = f"image_{i:03d}{ext}"
|
||||
|
||||
if self.upload_image(image_data, image_id, filename, brand):
|
||||
uploaded_count += 1
|
||||
key = f"daily/brands/{brand_low}/{image_id}/{filename}" if brand else f"daily/brands/products/{image_id}/{filename}"
|
||||
public_url = self.get_public_url(key)
|
||||
uploaded_urls.append(public_url)
|
||||
logger.info(f"✅ Successfully uploaded image {i+1}/{len(images_to_process)}")
|
||||
else:
|
||||
logger.warning(f"❌ Failed to upload image {i+1}/{len(images_to_process)}")
|
||||
@@ -217,10 +215,14 @@ class S3Service:
|
||||
except Exception as e:
|
||||
logger.warning(f"❌ Error processing image {i+1}/{len(images_to_process)} ({url}): {e}")
|
||||
|
||||
storage_brand_name = resolve_parent_brand(brand) if brand else brand
|
||||
brand_path = f"daily/brands/{storage_brand_name.lower()}/" if brand else "daily/brands/products/"
|
||||
logger.info(f"📸 Uploaded {uploaded_count}/{len(images_to_process)} images for product {product.get('title', 'Unknown')} to {brand_path}{image_id}")
|
||||
return image_id
|
||||
brand_path = f"daily/brands/{brand_low}/" if brand else "daily/brands/products/"
|
||||
logger.info(f"📸 Uploaded {len(uploaded_urls)}/{len(images_to_process)} images for product {product.get('title', 'Unknown')} to {brand_path}{image_id}")
|
||||
|
||||
cache_key = f"{brand_low}:{image_id}"
|
||||
if uploaded_urls:
|
||||
self._url_cache[cache_key] = uploaded_urls
|
||||
|
||||
return image_id, uploaded_urls
|
||||
|
||||
|
||||
def get_public_url(self, key: str) -> str:
|
||||
@@ -235,11 +237,13 @@ class S3Service:
|
||||
"""Construct or fetch the first image URL for a product in S3."""
|
||||
if not self.enabled or not image_id or not brand:
|
||||
return ""
|
||||
urls = self.get_product_image_urls(brand, image_id)
|
||||
if urls:
|
||||
return urls[0]
|
||||
storage_brand = resolve_parent_brand(brand)
|
||||
key = f"daily/brands/{storage_brand.lower()}/{image_id}/image_000.jpg"
|
||||
storage_brand = resolve_parent_brand(brand) if brand else "products"
|
||||
brand_low = storage_brand.lower() if brand else "products"
|
||||
cache_key = f"{brand_low}:{image_id}"
|
||||
if cache_key in self._url_cache and self._url_cache[cache_key]:
|
||||
return self._url_cache[cache_key][0]
|
||||
|
||||
key = f"daily/brands/{brand_low}/{image_id}/image_000.jpg"
|
||||
return self.get_public_url(key)
|
||||
|
||||
def get_product_image_urls(self, brand: str, image_id: str) -> List[str]:
|
||||
@@ -247,14 +251,14 @@ class S3Service:
|
||||
if not self.enabled or not image_id:
|
||||
return []
|
||||
|
||||
cache_key = f"{brand}:{image_id}"
|
||||
storage_brand = resolve_parent_brand(brand) if brand else brand
|
||||
brand_low = storage_brand.lower() if brand else "products"
|
||||
cache_key = f"{brand_low}:{image_id}"
|
||||
if cache_key in self._url_cache:
|
||||
return self._url_cache[cache_key]
|
||||
|
||||
try:
|
||||
# Try specific prefix patterns to find existing data
|
||||
storage_brand = resolve_parent_brand(brand) if brand else brand
|
||||
brand_low = storage_brand.lower() if brand else "products"
|
||||
prefixes = [
|
||||
f"daily/brands/{brand_low}/{image_id}/",
|
||||
f"daily/brands/products/{image_id}/",
|
||||
@@ -279,7 +283,7 @@ class S3Service:
|
||||
image_urls.append(self.get_public_url(key))
|
||||
|
||||
if image_urls:
|
||||
logger.info(f"✅ Found {len(image_urls)} images under prefix: {prefix}")
|
||||
logger.debug("Found %d images under prefix: %s", len(image_urls), prefix)
|
||||
res = sorted(image_urls)
|
||||
self._url_cache[cache_key] = res
|
||||
return res
|
||||
|
||||
@@ -11,6 +11,9 @@ from app.infrastructure.settings import DATABASE_URL, USE_PGVECTOR, DB_HOST, DB_
|
||||
from app.services.brand_registry import BRAND_ALIASES, resolve_parent_brand
|
||||
|
||||
|
||||
from app.services.s3_service import s3_service
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -50,9 +53,14 @@ def get_brand_table_ddl(brand: str) -> str:
|
||||
-- FSSAI license
|
||||
fssai_license TEXT,
|
||||
|
||||
-- Product SKU
|
||||
-- Product SKU & Tax/Price/Barcode details
|
||||
product_sku TEXT,
|
||||
sku_source TEXT,
|
||||
hsn_code TEXT,
|
||||
final_selling_price NUMERIC,
|
||||
selling_price NUMERIC,
|
||||
barcode TEXT,
|
||||
barcode_type TEXT,
|
||||
|
||||
-- Essential fields
|
||||
highlights TEXT[],
|
||||
@@ -106,6 +114,11 @@ def _ensure_columns(cur, table_name: str) -> None:
|
||||
"fssai_license": "TEXT",
|
||||
"product_sku": "TEXT",
|
||||
"sku_source": "TEXT",
|
||||
"hsn_code": "TEXT",
|
||||
"final_selling_price": "NUMERIC",
|
||||
"selling_price": "NUMERIC",
|
||||
"barcode": "TEXT",
|
||||
"barcode_type": "TEXT",
|
||||
"highlights": "TEXT[]",
|
||||
"nutrients": "TEXT[]",
|
||||
"search_query": "TEXT",
|
||||
@@ -127,7 +140,7 @@ def _ensure_columns(cur, table_name: str) -> None:
|
||||
logger.info(f"Added missing column '{col}' to {table_name}")
|
||||
|
||||
# 2. Relax legacy NOT NULL constraints on columns not present in standard insert
|
||||
inserted_cols = {"id", "product_name", "title", "description", "category", "image_id", "image_url", "image_urls", "price_range", "size_variants", "providers", "fssai_license", "product_sku", "sku_source", "highlights", "nutrients", "search_query", "embedding", "created_at", "updated_at"}
|
||||
inserted_cols = {"id", "product_name", "title", "description", "category", "image_id", "image_url", "image_urls", "price_range", "size_variants", "providers", "fssai_license", "product_sku", "sku_source", "hsn_code", "final_selling_price", "selling_price", "barcode", "barcode_type", "highlights", "nutrients", "search_query", "embedding", "created_at", "updated_at"}
|
||||
for col, is_nullable, col_def in col_info:
|
||||
if col not in inserted_cols and is_nullable == 'NO' and col_def is None:
|
||||
cur.execute(f"ALTER TABLE {table_name} ALTER COLUMN {col} DROP NOT NULL")
|
||||
@@ -205,6 +218,11 @@ def upsert_brand_products(brand: str, products: List[Dict[str, Any]], cleanup: b
|
||||
image_urls = []
|
||||
if not image_urls and image_url:
|
||||
image_urls = [image_url]
|
||||
if not image_urls and not image_url and image_id and s3_service.enabled:
|
||||
s3_single = s3_service.get_product_image_url(brand, image_id)
|
||||
if s3_single:
|
||||
image_url = s3_single
|
||||
image_urls = [s3_single]
|
||||
|
||||
# Essential pricing fields
|
||||
price_range = p.get("price_range") or ""
|
||||
@@ -227,10 +245,32 @@ def upsert_brand_products(brand: str, products: List[Dict[str, Any]], cleanup: b
|
||||
size_variants_str.append(variant)
|
||||
size_variants = size_variants_str
|
||||
|
||||
# Product SKU fields
|
||||
# Product SKU & HSN / Price / Barcode fields
|
||||
product_sku = p.get("product_sku") or ""
|
||||
sku_source = p.get("sku_source") or ""
|
||||
|
||||
hsn_code = str(p.get("hsn_code") or p.get("HSN_Code") or p.get("hsn") or "").strip() or None
|
||||
|
||||
raw_fsp = p.get("final_selling_price") if "final_selling_price" in p else p.get("Final_Selling_Price")
|
||||
if raw_fsp is None:
|
||||
raw_fsp = p.get("final_price")
|
||||
try:
|
||||
final_selling_price = float(raw_fsp) if raw_fsp is not None and str(raw_fsp).strip() != "" else None
|
||||
except (ValueError, TypeError):
|
||||
final_selling_price = None
|
||||
|
||||
raw_sp = p.get("selling_price") if "selling_price" in p else p.get("Selling_Price")
|
||||
try:
|
||||
selling_price = float(raw_sp) if raw_sp is not None and str(raw_sp).strip() != "" else None
|
||||
except (ValueError, TypeError):
|
||||
selling_price = None
|
||||
|
||||
if final_selling_price is None and selling_price is not None:
|
||||
final_selling_price = selling_price
|
||||
|
||||
barcode = str(p.get("barcode") or p.get("Barcode") or "").strip() or None
|
||||
barcode_type = str(p.get("barcode_type") or p.get("Barcode_Type") or "").strip() or None
|
||||
|
||||
# Essential fields
|
||||
highlights = p.get("highlights", [])
|
||||
if not isinstance(highlights, list):
|
||||
@@ -267,6 +307,11 @@ def upsert_brand_products(brand: str, products: List[Dict[str, Any]], cleanup: b
|
||||
fssai_license,
|
||||
product_sku,
|
||||
sku_source,
|
||||
hsn_code,
|
||||
final_selling_price,
|
||||
selling_price,
|
||||
barcode,
|
||||
barcode_type,
|
||||
highlights, # TEXT[] - psycopg will handle conversion
|
||||
nutrients, # TEXT[] - psycopg will handle conversion
|
||||
search_query,
|
||||
@@ -280,8 +325,8 @@ def upsert_brand_products(brand: str, products: List[Dict[str, Any]], cleanup: b
|
||||
f"""
|
||||
INSERT INTO {table_name}
|
||||
(product_name, title, description, category, image_id, image_url, image_urls, price_range, size_variants, providers,
|
||||
fssai_license, product_sku, sku_source, highlights, nutrients, search_query, embedding)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
fssai_license, product_sku, sku_source, hsn_code, final_selling_price, selling_price, barcode, barcode_type, highlights, nutrients, search_query, embedding)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (image_id) DO UPDATE SET
|
||||
product_name = EXCLUDED.product_name,
|
||||
title = EXCLUDED.title,
|
||||
@@ -295,6 +340,11 @@ def upsert_brand_products(brand: str, products: List[Dict[str, Any]], cleanup: b
|
||||
fssai_license = EXCLUDED.fssai_license,
|
||||
product_sku = EXCLUDED.product_sku,
|
||||
sku_source = EXCLUDED.sku_source,
|
||||
hsn_code = EXCLUDED.hsn_code,
|
||||
final_selling_price = EXCLUDED.final_selling_price,
|
||||
selling_price = EXCLUDED.selling_price,
|
||||
barcode = EXCLUDED.barcode,
|
||||
barcode_type = EXCLUDED.barcode_type,
|
||||
highlights = EXCLUDED.highlights,
|
||||
nutrients = EXCLUDED.nutrients,
|
||||
search_query = EXCLUDED.search_query,
|
||||
|
||||
Reference in New Issue
Block a user