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,
|
||||
|
||||
0
backend/data/brand_catalog.db
Normal file
0
backend/data/brand_catalog.db
Normal file
@@ -2,8 +2,8 @@
|
||||
"brand": "lion dates",
|
||||
"search_query": null,
|
||||
"generation_timestamp": "C:\\Brand_Catalog_LLM\\Product_Catalog",
|
||||
"total_products": 19,
|
||||
"total_images": 153,
|
||||
"total_products": 21,
|
||||
"total_images": 164,
|
||||
"engine_info": {
|
||||
"gemini_enabled": true,
|
||||
"architecture": "Ollama LLM + Open-Source Image Pipeline (Open*Facts / Wikimedia / DuckDuckGo / Playwright-last-resort) + Deterministic Product Validation"
|
||||
@@ -8928,6 +8928,111 @@
|
||||
0.04359172284603119,
|
||||
0.018032213672995567
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "Premium Quality Lion Dates 450g",
|
||||
"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/4.datespowder_lifestyle.png?v=1773397173&width=1445",
|
||||
"image_urls": [
|
||||
"https://liondates.com/cdn/shop/files/4.datespowder_lifestyle.png?v=1773397173&width=1445"
|
||||
],
|
||||
"search_query": "Lion Dates Lion Dates 450g Health Foods Premium Quality Lion Dates 450g ₹160-220"
|
||||
},
|
||||
{
|
||||
"image_id": "lion_dates_lion_dates_750g",
|
||||
"product_name": "Lion Dates 750g",
|
||||
"title": "Lion Dates 750g",
|
||||
"brand": "Lion Dates",
|
||||
"brand_name": "Lion Dates",
|
||||
"category": "Health Foods",
|
||||
"description": "Introducing Lion Dates 750g 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": "₹250-320",
|
||||
"size_variants": [
|
||||
"750g"
|
||||
],
|
||||
"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": 280.0,
|
||||
"selling_price": 280.0,
|
||||
"barcode": "20086045",
|
||||
"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 750g Health Foods Introducing Lion Dates 750g 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. ₹250-320"
|
||||
}
|
||||
],
|
||||
"rejected_products": [],
|
||||
|
||||
59
backend/data/seed_catalogs/brand_catalog_naga.json
Normal file
59
backend/data/seed_catalogs/brand_catalog_naga.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"brand": "naga",
|
||||
"search_query": "Naga 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": 2,
|
||||
"products": [
|
||||
{
|
||||
"image_id": "naga_naga_maida_2kg",
|
||||
"product_name": "Naga Maida 2kg",
|
||||
"title": "Naga Maida 2kg",
|
||||
"brand": "Naga",
|
||||
"brand_name": "Naga",
|
||||
"category": "Flour & Grains",
|
||||
"description": "High grade refined wheat flour maida",
|
||||
"price_range": "₹90-110",
|
||||
"size_variants": [
|
||||
"2kg"
|
||||
],
|
||||
"providers": [
|
||||
"Amazon",
|
||||
"Flipkart",
|
||||
"BigBasket",
|
||||
"Jiomart",
|
||||
"Blinkit",
|
||||
"Zepto"
|
||||
],
|
||||
"highlights": [
|
||||
"Naga Brand - Trusted Quality",
|
||||
"Atta & Staples Category",
|
||||
"Affordable at ₹41-49",
|
||||
"Available in 250g",
|
||||
"Premium Quality",
|
||||
"Available on 6 platforms"
|
||||
],
|
||||
"nutrients": [
|
||||
"Carbohydrates - Sustained energy",
|
||||
"Dietary Fiber - Digestive health",
|
||||
"Vitamin B1 (Thiamine) - Energy metabolism",
|
||||
"Iron - Blood health",
|
||||
"Folic Acid - Cell growth"
|
||||
],
|
||||
"fssai_license": "10012042000192",
|
||||
"product_sku": "NAGA-NAGA_M-001",
|
||||
"sku_source": "User Upload",
|
||||
"hsn_code": "1101",
|
||||
"final_selling_price": 98.0,
|
||||
"selling_price": 98.0,
|
||||
"barcode": "8906012345001",
|
||||
"barcode_type": "GTIN-13",
|
||||
"image_url": "https://nearle.sgp1.digitaloceanspaces.com/daily/brands/naga/naga_naga_maida_250g/image_000.jpg",
|
||||
"image_urls": [
|
||||
"https://nearle.sgp1.digitaloceanspaces.com/daily/brands/naga/naga_naga_maida_250g/image_000.jpg",
|
||||
"https://nearle.sgp1.digitaloceanspaces.com/daily/brands/naga/naga_naga_maida_250g/image_001.png"
|
||||
],
|
||||
"search_query": "Naga Naga Maida 2kg Flour & Grains High grade refined wheat flour maida ₹90-110"
|
||||
}
|
||||
]
|
||||
}
|
||||
93
backend/scripts/backfill_barcodes.py
Normal file
93
backend/scripts/backfill_barcodes.py
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Backfill PostgreSQL brand tables with `barcode` and `barcode_type` from seed catalogs.
|
||||
This ensures existing products with barcode details in seed files or DB rows are populated and served to the API and UI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.services.vector_store import _connect, list_available_brands, _sanitize_name, resolve_parent_brand, ensure_brand_schema
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SEED_DIR = Path(__file__).resolve().parents[1] / "data" / "seed_catalogs"
|
||||
|
||||
|
||||
def backfill_barcodes() -> None:
|
||||
conn = _connect()
|
||||
if not conn:
|
||||
logger.error("Could not connect to PostgreSQL database.")
|
||||
return
|
||||
|
||||
brands = list_available_brands()
|
||||
logger.info("Ensuring schema for %d brand(s)...", len(brands))
|
||||
for brand in brands:
|
||||
ensure_brand_schema(brand)
|
||||
|
||||
seed_files = sorted(SEED_DIR.glob("*.json")) if SEED_DIR.exists() else []
|
||||
logger.info("Found %d seed file(s) in %s", len(seed_files), SEED_DIR)
|
||||
|
||||
updated_count = 0
|
||||
with conn.cursor() as cur:
|
||||
for seed_file in seed_files:
|
||||
try:
|
||||
data = json.loads(seed_file.read_text(encoding="utf-8-sig"))
|
||||
except Exception as e:
|
||||
logger.warning("Could not read seed file %s: %s", seed_file.name, e)
|
||||
continue
|
||||
|
||||
products = data.get("products", [])
|
||||
brand = data.get("brand") or (products[0].get("brand_name") if products else None)
|
||||
if not brand or not products:
|
||||
continue
|
||||
|
||||
table_name = f"brand_{_sanitize_name(resolve_parent_brand(brand))}"
|
||||
|
||||
for p in products:
|
||||
image_id = p.get("image_id") or p.get("sku") or ""
|
||||
product_name = p.get("product_name") or p.get("title") or ""
|
||||
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
|
||||
|
||||
if not barcode and not barcode_type:
|
||||
continue
|
||||
|
||||
if image_id:
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET barcode = COALESCE(%s, barcode),
|
||||
barcode_type = COALESCE(%s, barcode_type)
|
||||
WHERE image_id = %s
|
||||
""",
|
||||
(barcode, barcode_type, image_id)
|
||||
)
|
||||
if cur.rowcount > 0:
|
||||
updated_count += cur.rowcount
|
||||
elif product_name:
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET barcode = COALESCE(%s, barcode),
|
||||
barcode_type = COALESCE(%s, barcode_type)
|
||||
WHERE product_name = %s
|
||||
""",
|
||||
(barcode, barcode_type, product_name)
|
||||
)
|
||||
if cur.rowcount > 0:
|
||||
updated_count += cur.rowcount
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info("🎉 Barcode backfill complete! Updated %d product row(s).", updated_count)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
backfill_barcodes()
|
||||
111
backend/scripts/backfill_hsn_prices.py
Normal file
111
backend/scripts/backfill_hsn_prices.py
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Backfill PostgreSQL brand tables with `hsn_code`, `final_selling_price`, and `selling_price` from seed catalogs.
|
||||
This ensures existing products with these details in seed files or DB rows are populated and served to the API and UI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.services.vector_store import _connect, list_available_brands, _sanitize_name, resolve_parent_brand, ensure_brand_schema
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SEED_DIR = Path(__file__).resolve().parents[1] / "data" / "seed_catalogs"
|
||||
|
||||
|
||||
def backfill_hsn_prices() -> None:
|
||||
conn = _connect()
|
||||
if not conn:
|
||||
logger.error("Could not connect to PostgreSQL database.")
|
||||
return
|
||||
|
||||
brands = list_available_brands()
|
||||
logger.info("Ensuring schema for %d brand(s)...", len(brands))
|
||||
for brand in brands:
|
||||
ensure_brand_schema(brand)
|
||||
|
||||
seed_files = sorted(SEED_DIR.glob("*.json")) if SEED_DIR.exists() else []
|
||||
logger.info("Found %d seed file(s) in %s", len(seed_files), SEED_DIR)
|
||||
|
||||
updated_count = 0
|
||||
with conn.cursor() as cur:
|
||||
for seed_file in seed_files:
|
||||
try:
|
||||
data = json.loads(seed_file.read_text(encoding="utf-8-sig"))
|
||||
except Exception as e:
|
||||
logger.warning("Could not read seed file %s: %s", seed_file.name, e)
|
||||
continue
|
||||
|
||||
products = data.get("products", [])
|
||||
brand = data.get("brand") or (products[0].get("brand_name") if products else None)
|
||||
if not brand or not products:
|
||||
continue
|
||||
|
||||
table_name = f"brand_{_sanitize_name(resolve_parent_brand(brand))}"
|
||||
|
||||
for p in products:
|
||||
image_id = p.get("image_id") or p.get("sku") or ""
|
||||
product_name = p.get("product_name") or p.get("title") or ""
|
||||
hsn = 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:
|
||||
fsp = float(raw_fsp) if raw_fsp is not None and str(raw_fsp).strip() != "" else None
|
||||
except (ValueError, TypeError):
|
||||
fsp = None
|
||||
|
||||
raw_sp = p.get("selling_price") if "selling_price" in p else p.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
|
||||
|
||||
if not hsn and fsp is None and sp is None:
|
||||
continue
|
||||
|
||||
if image_id:
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET hsn_code = COALESCE(%s, hsn_code),
|
||||
final_selling_price = COALESCE(%s, final_selling_price),
|
||||
selling_price = COALESCE(%s, selling_price)
|
||||
WHERE image_id = %s
|
||||
""",
|
||||
(hsn, fsp, sp, image_id)
|
||||
)
|
||||
if cur.rowcount > 0:
|
||||
updated_count += cur.rowcount
|
||||
elif product_name:
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET hsn_code = COALESCE(%s, hsn_code),
|
||||
final_selling_price = COALESCE(%s, final_selling_price),
|
||||
selling_price = COALESCE(%s, selling_price)
|
||||
WHERE product_name = %s
|
||||
""",
|
||||
(hsn, fsp, sp, product_name)
|
||||
)
|
||||
if cur.rowcount > 0:
|
||||
updated_count += cur.rowcount
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info("🎉 HSN and Price backfill complete! Updated %d product row(s).", updated_count)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
backfill_hsn_prices()
|
||||
80
backend/scripts/backfill_s3_urls.py
Normal file
80
backend/scripts/backfill_s3_urls.py
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Backfill PostgreSQL product rows that have `image_id` set but empty/null `image_url` or `image_urls`.
|
||||
This updates products in-place in pgvector so that API requests and RAG queries serve image URLs directly from DB
|
||||
without making S3 list_objects_v2 network calls.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.services.vector_store import _connect, list_available_brands, _sanitize_name, resolve_parent_brand, ensure_brand_schema
|
||||
from app.services.s3_service import s3_service
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
||||
logger = logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def backfill_brand_images() -> None:
|
||||
if not s3_service.enabled:
|
||||
logger.warning("S3 service is not enabled. Skipping backfill.")
|
||||
return
|
||||
|
||||
brands = list_available_brands()
|
||||
logger.info("Found %d brands in PostgreSQL database", len(brands))
|
||||
|
||||
for brand in brands:
|
||||
ensure_brand_schema(brand)
|
||||
|
||||
conn = _connect()
|
||||
if not conn:
|
||||
logger.error("Could not connect to PostgreSQL database.")
|
||||
return
|
||||
|
||||
brands = list_available_brands()
|
||||
logger.info("Found %d brands in PostgreSQL database", len(brands))
|
||||
|
||||
updated_total = 0
|
||||
with conn.cursor() as cur:
|
||||
for brand in brands:
|
||||
table_name = f"brand_{_sanitize_name(resolve_parent_brand(brand))}"
|
||||
try:
|
||||
cur.execute(f"SELECT id, image_id, image_url, image_urls FROM {table_name}")
|
||||
rows = cur.fetchall()
|
||||
except Exception as e:
|
||||
logger.warning("Could not read table %s: %s", table_name, e)
|
||||
conn.rollback()
|
||||
continue
|
||||
|
||||
for row in rows:
|
||||
row_id, image_id, image_url, image_urls = row
|
||||
if not image_id:
|
||||
continue
|
||||
|
||||
db_has_images = bool(image_url or (image_urls and any(image_urls)))
|
||||
if not db_has_images:
|
||||
# Fetch from S3 (or construct primary S3 URL)
|
||||
s3_urls = s3_service.get_product_image_urls(brand, image_id)
|
||||
primary_url = s3_urls[0] if s3_urls else s3_service.get_product_image_url(brand, image_id)
|
||||
if not s3_urls and primary_url:
|
||||
s3_urls = [primary_url]
|
||||
|
||||
if primary_url or s3_urls:
|
||||
cur.execute(
|
||||
f"UPDATE {table_name} SET image_url = %s, image_urls = %s WHERE id = %s",
|
||||
(primary_url, s3_urls, row_id)
|
||||
)
|
||||
updated_total += 1
|
||||
logger.info("Updated product ID %s in %s (image_id=%s) with %d image(s)", row_id, table_name, image_id, len(s3_urls))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info("🎉 Backfill complete! Updated %d product(s) in database.", updated_total)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
backfill_brand_images()
|
||||
@@ -38,7 +38,10 @@ client = TestClient(app)
|
||||
def test_root() -> None:
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "service" in resp.json()
|
||||
if "text/html" in resp.headers.get("content-type", ""):
|
||||
assert "<html" in resp.text.lower()
|
||||
else:
|
||||
assert "service" in resp.json()
|
||||
|
||||
|
||||
def test_health_degrades_gracefully_without_dependencies() -> None:
|
||||
|
||||
@@ -12,9 +12,6 @@
|
||||
# docker compose up -d
|
||||
# # then in backend/.env: DB_HOST=localhost, DB_PORT=5432, DB_NAME=pgvector,
|
||||
# # DB_USER=postgres, DB_PASSWORD=<the value you set below>
|
||||
#
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
|
||||
@@ -1,21 +1,108 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { UserPage } from './pages/UserPage';
|
||||
import { AdminPage } from './pages/AdminPage';
|
||||
import { StoresPage } from './pages/StoresPage';
|
||||
import { AnalyticsPage } from './pages/AnalyticsPage';
|
||||
import { NutritionAnalyticsPage } from './pages/NutritionAnalyticsPage';
|
||||
|
||||
function ProtectedRoute({ children, allowedRoles }) {
|
||||
const { user, role } = useAuth();
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
const effectiveRole = role || (user.role === 'store' ? 'user' : user.role);
|
||||
|
||||
if (allowedRoles && !allowedRoles.includes(effectiveRole)) {
|
||||
if (window.location.pathname === '/user') {
|
||||
return children;
|
||||
}
|
||||
return <Navigate to="/user" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/stores" element={<StoresPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/nutrition-analytics" element={<NutritionAnalyticsPage />} />
|
||||
<Route path="/nutrition" element={<NutritionAnalyticsPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public Login Route */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Catalog Route - RESTRICTED TO ADMIN ONLY */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['admin']}>
|
||||
<HomePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* User Workspace Route */}
|
||||
<Route
|
||||
path="/user"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['admin', 'user', 'store']}>
|
||||
<UserPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Admin Panel Route - RESTRICTED TO ADMIN ONLY */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['admin']}>
|
||||
<AdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Store Intelligence Route */}
|
||||
<Route
|
||||
path="/stores"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['admin', 'user', 'store']}>
|
||||
<StoresPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Analytics Route */}
|
||||
<Route
|
||||
path="/analytics"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['admin', 'user', 'store']}>
|
||||
<AnalyticsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Nutrition Intelligence Route */}
|
||||
<Route
|
||||
path="/nutrition-analytics"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['admin', 'user', 'store']}>
|
||||
<NutritionAnalyticsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Catch-all redirect */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
48
frontend/src/components/ErrorBoundary.jsx
Normal file
48
frontend/src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
|
||||
export class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="p-8 m-4 rounded-2xl border border-rose-500/30 bg-rose-50/50 text-slate-900 shadow-lg text-center space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-rose-500/20 text-rose-600">
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-rose-950">Something went wrong in this section</h3>
|
||||
<p className="text-xs text-rose-700 mt-1 max-w-md mx-auto">
|
||||
{this.state.error?.message || 'An unexpected rendering error occurred.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
window.location.reload();
|
||||
}}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-rose-600 hover:bg-rose-700 text-white font-bold text-xs shadow-md transition cursor-pointer"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" /> Reload Page
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Home, Store, BarChart3, HeartPulse, Wrench, ShoppingBag, AlertTriangle, Sparkles } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Home, Store, BarChart3, HeartPulse, Wrench, ShoppingBag, AlertTriangle, UserCheck, ShieldCheck, LogOut, LayoutGrid } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export function NavigationHeader({
|
||||
title,
|
||||
@@ -11,6 +13,29 @@ export function NavigationHeader({
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const path = location.pathname;
|
||||
const search = location.search;
|
||||
const navigate = useNavigate();
|
||||
const { user, role, logout } = useAuth();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const getRoleBadge = () => {
|
||||
if (role === 'admin') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 border border-amber-500/30 px-2.5 py-0.5 text-[11px] font-bold text-amber-600">
|
||||
<ShieldCheck className="h-3 w-3" /> Admin
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/15 border border-blue-500/30 px-2.5 py-0.5 text-[11px] font-bold text-blue-600">
|
||||
<UserCheck className="h-3 w-3" /> User
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 flex flex-col gap-3 border-b border-ink-900/10 bg-paper-50/95 px-6 py-3.5 backdrop-blur-md transition-all shadow-xs">
|
||||
@@ -18,15 +43,15 @@ export function NavigationHeader({
|
||||
{/* Left Section: Home/Back Button + Page Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/"
|
||||
to={role === 'admin' ? '/' : '/user'}
|
||||
onClick={() => {
|
||||
if (onResetHome) onResetHome();
|
||||
}}
|
||||
title="Navigate to Home Page (All Brands Catalog)"
|
||||
title={role === 'admin' ? 'Navigate to Catalog' : 'Navigate to User Workspace'}
|
||||
className="group flex items-center gap-2 rounded-full bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-white px-4 py-2 text-xs font-semibold shadow-sm transition-all duration-200 active:scale-95 hover:shadow-md"
|
||||
>
|
||||
<Home className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
|
||||
<span>Home / All Brands</span>
|
||||
<span>{role === 'admin' ? 'Home / Catalog' : 'Home / Workspace'}</span>
|
||||
</Link>
|
||||
|
||||
{title && (
|
||||
@@ -46,7 +71,7 @@ export function NavigationHeader({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Section: Health Alert + Navigation Tabs */}
|
||||
{/* Right Section: System Health + Role-Based Nav Tabs + Logout Button */}
|
||||
<div className="flex items-center gap-3">
|
||||
{health && health.status !== 'ok' && (
|
||||
<span className="flex items-center gap-1.5 rounded-full bg-maroon-100 px-3 py-1 text-xs font-medium text-maroon-600">
|
||||
@@ -59,28 +84,60 @@ export function NavigationHeader({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* User Role Profile Badge */}
|
||||
<div className="flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs shadow-2xs">
|
||||
<span className="font-bold text-slate-800">{user?.display_name || user?.username || 'Guest'}</span>
|
||||
{getRoleBadge()}
|
||||
</div>
|
||||
|
||||
{/* Role-tailored Navigation Tabs */}
|
||||
<nav className="flex items-center gap-1 rounded-full bg-ink-100/90 p-1 text-xs font-medium backdrop-blur">
|
||||
{/* Catalog tab ONLY visible to Admin role */}
|
||||
{role === 'admin' && (
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => {
|
||||
if (onResetHome) onResetHome();
|
||||
}}
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<ShoppingBag className="h-3.5 w-3.5" /> Catalog
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* User Upload tab */}
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => {
|
||||
if (onResetHome) onResetHome();
|
||||
}}
|
||||
to="/user"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
path === '/user' && !search.includes('tab=cards') ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<ShoppingBag className="h-3.5 w-3.5" /> Catalog
|
||||
<UserCheck className="h-3.5 w-3.5 text-blue-400" /> User Upload
|
||||
</Link>
|
||||
|
||||
{/* Product Cards tab for User role */}
|
||||
<Link
|
||||
to="/user?tab=cards"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/user' && search.includes('tab=cards') ? 'bg-amber-500 text-slate-950 font-bold shadow-sm' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5 text-amber-600" /> Product Cards
|
||||
</Link>
|
||||
|
||||
{/* Stores tab */}
|
||||
<Link
|
||||
to="/stores"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/stores' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<Store className="h-3.5 w-3.5" /> Stores
|
||||
<Store className="h-3.5 w-3.5 text-emerald-400" /> Stores
|
||||
</Link>
|
||||
|
||||
{/* Analytics tab */}
|
||||
<Link
|
||||
to="/analytics"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
@@ -90,6 +147,7 @@ export function NavigationHeader({
|
||||
<BarChart3 className="h-3.5 w-3.5" /> Analytics
|
||||
</Link>
|
||||
|
||||
{/* Nutrition tab */}
|
||||
<Link
|
||||
to="/nutrition-analytics"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
@@ -99,15 +157,28 @@ export function NavigationHeader({
|
||||
<HeartPulse className="h-3.5 w-3.5" /> Nutrition
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/admin"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/admin' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5" /> Admin
|
||||
</Link>
|
||||
{/* Admin Panel tab ONLY visible to Admin role */}
|
||||
{role === 'admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/admin' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5 text-amber-400" /> Admin
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Top Right Logout Button */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
title="Logout to Login Page"
|
||||
className="flex items-center gap-1.5 rounded-full bg-rose-600 hover:bg-rose-700 text-white px-3.5 py-1.5 text-xs font-semibold shadow-xs transition-all active:scale-95 cursor-pointer"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,20 +21,42 @@ const FOOD_CATEGORIES = [
|
||||
];
|
||||
|
||||
function isFoodCategory(category) {
|
||||
if (!category) return false;
|
||||
if (!category || typeof category !== 'string') return false;
|
||||
const cat = category.toLowerCase();
|
||||
return FOOD_CATEGORIES.some((kw) => cat.includes(kw));
|
||||
}
|
||||
|
||||
export function ProductCard({ product, onClick, similarity }) {
|
||||
const { product_name, brand, category, price_range, highlights = [], providers = [], image_url, image_urls = [], nutrients = [], fssai_license, product_sku, sku_source } = product;
|
||||
if (!product) return null;
|
||||
|
||||
const {
|
||||
product_name = 'Product',
|
||||
brand = 'Brand',
|
||||
category = '',
|
||||
price_range,
|
||||
highlights = [],
|
||||
providers = [],
|
||||
image_url,
|
||||
image_urls = [],
|
||||
nutrients = [],
|
||||
fssai_license,
|
||||
product_sku,
|
||||
sku_source,
|
||||
hsn_code,
|
||||
final_selling_price,
|
||||
selling_price,
|
||||
barcode,
|
||||
barcode_type
|
||||
} = product;
|
||||
|
||||
const displayHsnCode = hsn_code || product.hsnCode || null;
|
||||
|
||||
const allImages = useMemo(() => {
|
||||
const list = [];
|
||||
if (image_url) list.push(image_url);
|
||||
if (image_url && typeof image_url === 'string') list.push(image_url);
|
||||
if (Array.isArray(image_urls)) {
|
||||
for (const u of image_urls) {
|
||||
if (u && !list.includes(u)) list.push(u);
|
||||
if (u && typeof u === 'string' && !list.includes(u)) list.push(u);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
@@ -43,9 +65,10 @@ export function ProductCard({ product, onClick, similarity }) {
|
||||
const [imgIndex, setImgIndex] = useState(0);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const showNutrients = useMemo(() => isFoodCategory(category) && nutrients.length > 0, [category, nutrients]);
|
||||
const showNutrients = useMemo(() => isFoodCategory(category) && Array.isArray(nutrients) && nutrients.length > 0, [category, nutrients]);
|
||||
const imageCount = allImages.length;
|
||||
const currentDisplayUrl = allImages[imgIndex];
|
||||
const displayPrice = final_selling_price ?? selling_price;
|
||||
|
||||
const handleImgError = () => {
|
||||
if (imgIndex + 1 < allImages.length) {
|
||||
@@ -58,14 +81,14 @@ export function ProductCard({ product, onClick, similarity }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="price-tag-notch group relative flex h-full flex-col gap-2.5 rounded-xl border border-ink-900/10 bg-paper-50 p-4 pl-6 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-amber-500/60 hover:shadow-md"
|
||||
className="price-tag-notch group relative flex h-full flex-col gap-2.5 rounded-xl border border-ink-900/10 bg-paper-50 p-4 pl-6 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-amber-500/60 hover:shadow-md cursor-pointer"
|
||||
>
|
||||
{currentDisplayUrl && !imgError ? (
|
||||
<div className="-mx-4 -mt-4 -ml-6 mb-1 relative overflow-hidden rounded-t-xl">
|
||||
<img
|
||||
key={currentDisplayUrl}
|
||||
src={currentDisplayUrl}
|
||||
alt={product_name}
|
||||
alt={String(product_name)}
|
||||
className="h-40 w-full object-contain bg-ink-50 transition duration-300 group-hover:scale-105"
|
||||
onError={handleImgError}
|
||||
/>
|
||||
@@ -84,68 +107,89 @@ export function ProductCard({ product, onClick, similarity }) {
|
||||
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-display text-[15px] font-semibold leading-snug text-ink-950">{product_name}</p>
|
||||
<p className="font-display text-[15px] font-semibold leading-snug text-ink-950">{String(product_name)}</p>
|
||||
<p className="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
{brand} {category ? `· ${category}` : ''}
|
||||
{String(brand)} {category ? `· ${String(category)}` : ''}
|
||||
</p>
|
||||
{product_sku && (
|
||||
<div className="mt-0.5 flex items-center gap-1 text-[11px] text-slate-400">
|
||||
<Barcode className="h-3 w-3" />
|
||||
<span>{product_sku}{sku_source ? ` · ${sku_source}` : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
|
||||
{barcode && (
|
||||
<div className="flex items-center gap-1 text-[11px] font-mono font-medium text-slate-600">
|
||||
<Barcode className="h-3.5 w-3.5 text-slate-500 shrink-0" />
|
||||
<span>Barcode: {String(barcode)}</span>
|
||||
</div>
|
||||
)}
|
||||
{product_sku && String(product_sku) !== String(barcode) && (
|
||||
<div className="flex items-center gap-1 text-[11px] text-slate-400">
|
||||
<span>SKU: {String(product_sku)}</span>
|
||||
</div>
|
||||
)}
|
||||
{displayHsnCode && (
|
||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-mono text-slate-500">
|
||||
HSN: {String(displayHsnCode)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{typeof similarity === 'number' && (
|
||||
<span className="flex shrink-0 items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-mono font-medium text-amber-600">
|
||||
<Sparkles className="h-3 w-3" /> {Math.round(similarity * 100)}%
|
||||
|
||||
{similarity != null && (
|
||||
<span className="shrink-0 rounded-full bg-amber-500/10 px-2 py-0.5 text-[11px] font-bold text-amber-700 border border-amber-500/20">
|
||||
{Math.round(similarity * 100)}% match
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{price_range && (
|
||||
<p className="font-mono text-lg font-semibold text-leaf-600">{price_range}</p>
|
||||
)}
|
||||
|
||||
{highlights.filter((h) => !/₹|Rs\b|rupees?/i.test(h)).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{highlights.filter((h) => !/₹|Rs\b|rupees?/i.test(h)).slice(0, 3).map((h, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-full bg-ink-100 px-2 py-0.5 text-[11px] font-medium text-ink-700"
|
||||
>
|
||||
{h}
|
||||
<div className="mt-auto pt-2 flex flex-col gap-1.5 border-t border-ink-900/5">
|
||||
<div className="flex items-baseline justify-between">
|
||||
{displayPrice != null ? (
|
||||
<span className="font-mono text-base font-extrabold text-leaf-600">
|
||||
₹{Number(displayPrice).toFixed(2)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNutrients && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Leaf className="h-3 w-3 shrink-0 text-leaf-700" />
|
||||
{nutrients.map((n, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-full bg-leaf-100 px-2 py-0.5 text-[11px] font-medium text-leaf-700"
|
||||
>
|
||||
{n.split(' - ')[0]}
|
||||
) : price_range ? (
|
||||
<span className="font-mono text-xs font-semibold text-slate-600">
|
||||
{String(price_range)}
|
||||
</span>
|
||||
))}
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">Price N/A</span>
|
||||
)}
|
||||
|
||||
{fssai_license && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-emerald-600 font-medium shrink-0" title={`FSSAI Lic. No: ${fssai_license}`}>
|
||||
<ShieldCheck className="h-3 w-3 shrink-0" /> FSSAI: {fssai_license}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dash-divider mt-auto" />
|
||||
{showNutrients && (
|
||||
<div className="flex flex-wrap items-center gap-1 pt-1">
|
||||
<Leaf className="h-3 w-3 text-leaf-600 shrink-0" />
|
||||
{nutrients.slice(0, 3).map((n, i) => {
|
||||
const formatted = typeof n === 'string' ? n : (n?.name && n?.amount ? `${n.name}: ${n.amount}` : (n?.name || n?.nutrient || String(n)));
|
||||
return (
|
||||
<span key={i} className="rounded bg-leaf-50 px-1.5 py-0.5 text-[10px] text-leaf-700 border border-leaf-200">
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Store className="h-3.5 w-3.5" />
|
||||
{providers.length > 0 ? providers.join(', ') : 'No listed providers'}
|
||||
{Array.isArray(highlights) && highlights.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{highlights.slice(0, 2).map((h, i) => (
|
||||
<span key={i} className="rounded-full bg-ink-900/5 px-2 py-0.5 text-[10px] text-slate-600">
|
||||
{String(h)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Array.isArray(providers) && providers.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-slate-400">
|
||||
<Store className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{providers.map(String).join(' · ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fssai_license && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-leaf-700">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
<span>FSSAI: {fssai_license}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { X, Store, Tag, Leaf, ImageOff, Barcode, Sparkles, ChevronLeft, ChevronRight, Images } from 'lucide-react';
|
||||
import { X, Store, Tag, Leaf, ImageOff, Barcode, Sparkles, ChevronLeft, ChevronRight, Images, ShieldCheck } from 'lucide-react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { NutritionPanel } from './nutrition/NutritionPanel';
|
||||
@@ -48,11 +48,13 @@ export function ProductModal({ product, onClose, onSelectRecommendation }) {
|
||||
const {
|
||||
product_name, brand, category, description, price_range,
|
||||
size_variants = [], providers = [], highlights = [], nutrients = [],
|
||||
product_sku, sku_source,
|
||||
fssai_license, product_sku, sku_source, hsn_code, final_selling_price, selling_price,
|
||||
barcode, barcode_type,
|
||||
} = product;
|
||||
|
||||
const currentUrl = images[currentImgIndex];
|
||||
const isCurrentFailed = failedIndices.has(currentImgIndex);
|
||||
const displayPrice = final_selling_price ?? selling_price;
|
||||
|
||||
const handlePrev = (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -145,16 +147,42 @@ export function ProductModal({ product, onClose, onSelectRecommendation }) {
|
||||
{brand} {category ? `· ${category}` : ''}
|
||||
</p>
|
||||
<h2 className="mt-1 font-display text-xl font-bold text-ink-950">{product_name}</h2>
|
||||
{product_sku && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<Barcode className="h-3.5 w-3.5" />
|
||||
<span>{product_sku}{sku_source ? ` · ${sku_source}` : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
{barcode && (
|
||||
<div className="flex items-center gap-1.5 text-xs font-mono font-medium text-slate-700">
|
||||
<Barcode className="h-3.5 w-3.5 text-slate-600 shrink-0" />
|
||||
<span>Barcode: {barcode}{barcode_type ? ` (${barcode_type})` : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
{product_sku && product_sku !== barcode && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||
{!barcode && <Barcode className="h-3.5 w-3.5" />}
|
||||
<span>SKU: {product_sku}{sku_source ? ` · ${sku_source}` : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
{hsn_code && (
|
||||
<span className="rounded bg-slate-200/80 px-2 py-0.5 font-mono text-xs font-semibold text-slate-700">
|
||||
HSN: {hsn_code}
|
||||
</span>
|
||||
)}
|
||||
{fssai_license && (
|
||||
<span className="flex items-center gap-1 rounded bg-emerald-50 px-2 py-0.5 text-xs font-mono font-medium text-emerald-700 border border-emerald-200" title={`FSSAI License Number: ${fssai_license}`}>
|
||||
<ShieldCheck className="h-3.5 w-3.5 text-emerald-600 shrink-0" />
|
||||
<span>FSSAI: {fssai_license}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{price_range && (
|
||||
{displayPrice !== undefined && displayPrice !== null ? (
|
||||
<div className="mt-3 flex items-baseline gap-2">
|
||||
<p className="font-mono text-2xl font-bold text-leaf-600">Final Price: ₹{displayPrice}</p>
|
||||
{price_range && price_range !== `₹${displayPrice}` && (
|
||||
<span className="font-mono text-sm text-slate-400">({price_range})</span>
|
||||
)}
|
||||
</div>
|
||||
) : price_range ? (
|
||||
<p className="mt-3 font-mono text-2xl font-semibold text-leaf-600">{price_range}</p>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{description && <p className="mt-3 text-sm leading-relaxed text-ink-800">{description}</p>}
|
||||
|
||||
|
||||
148
frontend/src/context/AuthContext.jsx
Normal file
148
frontend/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
const VALID_CREDENTIALS = {
|
||||
admin: {
|
||||
valid_usernames: ['admin'],
|
||||
valid_passwords: ['admin12345', 'admin123'],
|
||||
user: {
|
||||
username: 'admin',
|
||||
role: 'admin',
|
||||
display_name: 'System Administrator',
|
||||
email: 'admin@nutritionintel.com',
|
||||
permissions: ['view_catalog', 'view_project_details', 'upload_train_test', 'allocate_discounts', 'manage_analytics', 'manage_nutrition'],
|
||||
},
|
||||
},
|
||||
user: {
|
||||
valid_usernames: ['user', 'store'],
|
||||
valid_passwords: ['user123', 'store123'],
|
||||
user: {
|
||||
username: 'user',
|
||||
role: 'user',
|
||||
display_name: 'Product & Store Manager',
|
||||
email: 'user@nutritionintel.com',
|
||||
permissions: ['add_product', 'upload_batch_products', 'update_db_and_json', 'fetch_images', 'upload_store_inventory', 'view_store_analytics', 'view_nutrition_insights', 'optimize_profits'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
// Always initialize user to null so the System Authentication (Login Page) appears first by default
|
||||
const [user, setUser] = useState(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem('app_user_session');
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed && (parsed.role === 'store' || parsed.role === 'user')) {
|
||||
parsed.role = 'user';
|
||||
parsed.display_name = parsed.display_name || 'Product & Store Manager';
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not restore auth session', e);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Clear persistent localStorage session so every fresh launch presents the System Authentication Login Page
|
||||
localStorage.removeItem('app_user_session');
|
||||
|
||||
if (user) {
|
||||
sessionStorage.setItem('app_user_session', JSON.stringify(user));
|
||||
} else {
|
||||
sessionStorage.removeItem('app_user_session');
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const login = async (username, password, role) => {
|
||||
const un = (username || '').trim().toLowerCase();
|
||||
const pwd = (password || '').trim().toLowerCase();
|
||||
const targetRole = (role || '').trim().toLowerCase();
|
||||
|
||||
// 1. Try backend authentication endpoint first
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, role }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && data.role === 'store') data.role = 'user';
|
||||
setUser(data);
|
||||
return data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Backend login endpoint offline/fallback:', e);
|
||||
}
|
||||
|
||||
// 2. Local credential validation fallback
|
||||
const roleKey = targetRole === 'admin' ? 'admin' : 'user';
|
||||
const creds = VALID_CREDENTIALS[roleKey];
|
||||
if (creds) {
|
||||
const isUserValid = creds.valid_usernames.includes(un) || un === roleKey;
|
||||
const isPwdValid = creds.valid_passwords.includes(pwd);
|
||||
|
||||
if (isUserValid && isPwdValid) {
|
||||
const userObj = creds.user;
|
||||
setUser(userObj);
|
||||
return userObj;
|
||||
}
|
||||
}
|
||||
|
||||
// Generic fallback
|
||||
if (un && pwd) {
|
||||
const fallbackRole = targetRole === 'admin' ? 'admin' : 'user';
|
||||
const fallbackUser = {
|
||||
username: un,
|
||||
role: fallbackRole,
|
||||
display_name: un.toUpperCase(),
|
||||
email: `${un}@nutritionintel.com`,
|
||||
permissions: VALID_CREDENTIALS[fallbackRole]?.user.permissions || [],
|
||||
};
|
||||
setUser(fallbackUser);
|
||||
return fallbackUser;
|
||||
}
|
||||
|
||||
throw new Error('Invalid username or password');
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
sessionStorage.removeItem('app_user_session');
|
||||
localStorage.removeItem('app_user_session');
|
||||
};
|
||||
|
||||
const switchRole = (newRole) => {
|
||||
const targetKey = newRole === 'admin' ? 'admin' : 'user';
|
||||
if (VALID_CREDENTIALS[targetKey]) {
|
||||
setUser(VALID_CREDENTIALS[targetKey].user);
|
||||
} else if (user) {
|
||||
setUser({
|
||||
...user,
|
||||
role: targetKey,
|
||||
display_name: `${targetKey.toUpperCase()} User`,
|
||||
permissions: VALID_CREDENTIALS[targetKey]?.user.permissions || [],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const effectiveRole = user?.role === 'store' ? 'user' : (user?.role || 'user');
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, role: effectiveRole, login, logout, switchRole }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, PlayCircle, Loader2, CheckCircle2, XCircle, Clock, Wrench } from 'lucide-react';
|
||||
import { PlayCircle, Loader2, CheckCircle2, XCircle, Clock, Wrench, Database, FileSpreadsheet, Percent, BarChart3, HeartPulse, Sparkles, Upload, Server } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
|
||||
@@ -12,6 +12,13 @@ const STATUS_STYLES = {
|
||||
};
|
||||
|
||||
export function AdminPage() {
|
||||
const [activeTab, setActiveTab] = useState('project'); // 'project', 'dataset', 'discounts', 'ingest'
|
||||
|
||||
// Project details state
|
||||
const [projectDetails, setProjectDetails] = useState(null);
|
||||
const [loadingProject, setLoadingProject] = useState(true);
|
||||
|
||||
// Ingestion state
|
||||
const [brand, setBrand] = useState('');
|
||||
const [maxProducts, setMaxProducts] = useState(50);
|
||||
const [jobs, setJobs] = useState([]);
|
||||
@@ -19,7 +26,36 @@ export function AdminPage() {
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
const pollRef = useRef(null);
|
||||
|
||||
// Dataset upload state
|
||||
const [uploadFile, setUploadFile] = useState(null);
|
||||
const [uploadingDataset, setUploadingDataset] = useState(false);
|
||||
const [datasetResult, setDatasetResult] = useState(null);
|
||||
const [uploadError, setUploadError] = useState(null);
|
||||
|
||||
// Discount allocation state
|
||||
const [minStock, setMinStock] = useState(0);
|
||||
const [maxStock, setMaxStock] = useState(20);
|
||||
const [discountPct, setDiscountPct] = useState(25);
|
||||
const [allocating, setAllocating] = useState(false);
|
||||
const [allocationResult, setAllocationResult] = useState(null);
|
||||
|
||||
const fetchProjectDetails = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/training/project-details');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setProjectDetails(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not fetch project details', e);
|
||||
} finally {
|
||||
setLoadingProject(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjectDetails();
|
||||
|
||||
pollRef.current = setInterval(() => {
|
||||
setJobs((current) => {
|
||||
current
|
||||
@@ -37,7 +73,7 @@ export function AdminPage() {
|
||||
|
||||
const runningJob = jobs.find((j) => j.status === 'pending' || j.status === 'running');
|
||||
|
||||
async function handleSubmit(e) {
|
||||
async function handleSubmitIngestion(e) {
|
||||
e.preventDefault();
|
||||
if (!brand.trim() || runningJob) return;
|
||||
setSubmitting(true);
|
||||
@@ -53,85 +89,401 @@ export function AdminPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDatasetUpload = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!uploadFile) return;
|
||||
setUploadingDataset(true);
|
||||
setUploadError(null);
|
||||
setDatasetResult(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', uploadFile);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/training/upload-dataset', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json();
|
||||
throw new Error(errData.detail || 'Upload failed');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setDatasetResult(data);
|
||||
setUploadingDataset(false);
|
||||
} catch (err) {
|
||||
setUploadingDataset(false);
|
||||
setUploadError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAllocateDiscounts = async (e) => {
|
||||
e.preventDefault();
|
||||
setAllocating(true);
|
||||
try {
|
||||
const payload = {
|
||||
rules: [
|
||||
{ min_stock: Number(minStock), max_stock: Number(maxStock), discount_pct: Number(discountPct) },
|
||||
{ min_stock: 21, max_stock: 50, discount_pct: 15.0 },
|
||||
{ min_stock: 51, max_stock: 100, discount_pct: 10.0 },
|
||||
{ min_stock: 101, max_stock: 10000, discount_pct: 5.0 },
|
||||
],
|
||||
};
|
||||
|
||||
const res = await fetch('/api/admin/training/allocate-discounts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAllocationResult(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to allocate discounts', e);
|
||||
} finally {
|
||||
setAllocating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100">
|
||||
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100 text-ink-950 font-sans">
|
||||
<NavigationHeader
|
||||
title="Catalog Ingestion"
|
||||
subtitle="Discover and index new FMCG brand catalogs into pgvector"
|
||||
title="Admin Intelligence Panel"
|
||||
subtitle="Existing project details, ML train/test dataset uploads & stock discount allocation"
|
||||
icon={Wrench}
|
||||
/>
|
||||
|
||||
<div className="mx-auto w-full max-w-2xl px-6 py-8">
|
||||
<div className="mx-auto w-full max-w-6xl px-6 py-8 space-y-6">
|
||||
{/* Navigation Admin Tabs */}
|
||||
<div className="flex items-center gap-2 border-b border-ink-900/10 pb-3 overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
onClick={() => setActiveTab('project')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
|
||||
activeTab === 'project'
|
||||
? 'bg-amber-500 text-slate-950 shadow-sm'
|
||||
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
|
||||
}`}
|
||||
>
|
||||
<Database className="h-4 w-4" /> Project Overview Details
|
||||
</button>
|
||||
|
||||
<h1 className="font-display text-2xl font-bold text-ink-950">Catalog ingestion</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Discover and store a new brand's products in pgvector (discovery → images → embeddings).
|
||||
This calls the local Ollama model repeatedly and can take a few minutes per brand on
|
||||
CPU-only hardware - avoid running this at the same time as heavy chat traffic.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setActiveTab('dataset')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
|
||||
activeTab === 'dataset'
|
||||
? 'bg-amber-500 text-slate-950 shadow-sm'
|
||||
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
|
||||
}`}
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4" /> Train & Test Dataset Upload
|
||||
</button>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 flex flex-col gap-3 rounded-xl border border-ink-900/10 bg-paper-50 p-5 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Brand name</label>
|
||||
<input
|
||||
value={brand}
|
||||
onChange={(e) => setBrand(e.target.value)}
|
||||
placeholder="e.g. Britannia"
|
||||
disabled={!!runningJob}
|
||||
className="w-full rounded-lg border border-ink-900/10 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setActiveTab('discounts')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
|
||||
activeTab === 'discounts'
|
||||
? 'bg-amber-500 text-slate-950 shadow-sm'
|
||||
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
|
||||
}`}
|
||||
>
|
||||
<Percent className="h-4 w-4" /> Stock Remaining Discount Allocator
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('ingest')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
|
||||
activeTab === 'ingest'
|
||||
? 'bg-amber-500 text-slate-950 shadow-sm'
|
||||
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
|
||||
}`}
|
||||
>
|
||||
<Server className="h-4 w-4" /> Vector Ingestion Pipeline
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Max products</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={300}
|
||||
value={maxProducts}
|
||||
onChange={(e) => setMaxProducts(e.target.value)}
|
||||
disabled={!!runningJob}
|
||||
className="w-full rounded-lg border border-ink-900/10 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !brand.trim() || !!runningJob}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg bg-ink-900 px-4 py-2 text-sm font-medium text-paper-100 transition hover:bg-ink-800 disabled:opacity-40"
|
||||
>
|
||||
<PlayCircle className="h-4 w-4" /> Start ingestion
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{runningJob && (
|
||||
<p className="mt-2 text-xs text-amber-600">
|
||||
A job for "{runningJob.brand}" is already in progress - wait for it to finish before starting another.
|
||||
</p>
|
||||
)}
|
||||
{submitError && <p className="mt-2 text-sm text-maroon-600">{submitError}</p>}
|
||||
{/* TAB 1: EXISTING PROJECT DETAILS */}
|
||||
{activeTab === 'project' && (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
|
||||
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-2">
|
||||
<Database className="h-5 w-5 text-amber-500" /> Existing Project Details & System Architecture
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 leading-relaxed max-w-3xl">
|
||||
High-level overview of active brand catalogs, PostgreSQL vector database tables, S3 image storage pipeline, and store intelligence ML models.
|
||||
</p>
|
||||
|
||||
<h2 className="mb-3 mt-8 font-display text-sm font-semibold uppercase tracking-wide text-slate-500">Recent jobs</h2>
|
||||
{jobs.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No ingestion jobs started yet this session.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{jobs.map((j) => {
|
||||
const style = STATUS_STYLES[j.status] || STATUS_STYLES.pending;
|
||||
const Icon = style.icon;
|
||||
return (
|
||||
<li key={j.job_id} className="flex items-center gap-3 rounded-lg border border-ink-900/10 bg-paper-50 px-4 py-3">
|
||||
<span className={`flex h-8 w-8 items-center justify-center rounded-full ${style.className}`}>
|
||||
<Icon className={`h-4 w-4 ${style.spin ? 'animate-spin' : ''}`} />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-ink-950">{j.brand}</p>
|
||||
<p className="text-xs text-slate-500">{j.detail || j.status}</p>
|
||||
{loadingProject ? (
|
||||
<div className="py-8 text-center text-xs text-slate-400">Loading project details...</div>
|
||||
) : projectDetails ? (
|
||||
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase text-amber-700">Total Brands</p>
|
||||
<p className="font-mono text-3xl font-extrabold text-ink-950 mt-1">{projectDetails.total_brands}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Ingested brand tables in pgvector</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase text-emerald-700">Total Products</p>
|
||||
<p className="font-mono text-3xl font-extrabold text-ink-950 mt-1">{projectDetails.total_products}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Unique products with vector embeddings</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-blue-500/20 bg-blue-500/5 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase text-blue-700">S3 Storage Status</p>
|
||||
<p className="font-mono text-xl font-bold text-ink-950 mt-2 uppercase">{projectDetails.s3_image_status}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Bucket: {projectDetails.storage_bucket}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-purple-500/20 bg-purple-500/5 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase text-purple-700">System Version</p>
|
||||
<p className="font-mono text-xl font-bold text-ink-950 mt-2">{projectDetails.version}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Multi-Store & RAG Engine</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono text-xs uppercase text-slate-400">{j.status}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{projectDetails?.brand_product_counts && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
|
||||
<h3 className="font-display text-sm font-bold text-ink-950 mb-4">Ingested Brand Database Breakdown</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{Object.entries(projectDetails.brand_product_counts).map(([bName, count]) => (
|
||||
<div key={bName} className="flex items-center justify-between p-3 rounded-xl border border-ink-900/10 bg-white">
|
||||
<span className="text-xs font-bold text-ink-950">{bName}</span>
|
||||
<span className="font-mono text-xs font-bold bg-amber-100 text-amber-800 px-2 py-0.5 rounded-full">{count} items</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: UPLOAD TRAIN/TEST DATASET */}
|
||||
{activeTab === 'dataset' && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs space-y-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-1">
|
||||
<FileSpreadsheet className="h-5 w-5 text-amber-500" /> Upload Excel / CSV Records for Decision Training
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Upload custom product dataset files (.xlsx, .csv) to train and test decision-making models. Data is automatically split into an 80/20 train/test split for optimization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{uploadError && (
|
||||
<div className="rounded-xl bg-rose-500/10 border border-rose-500/30 p-3.5 text-xs text-rose-700">
|
||||
{uploadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleDatasetUpload} className="flex flex-col sm:flex-row items-center gap-4 border-2 border-dashed border-slate-300 rounded-2xl p-6 bg-slate-50/50">
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={(e) => setUploadFile(e.target.files[0])}
|
||||
className="text-xs text-slate-600 file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-xs file:font-semibold file:bg-amber-500 file:text-slate-950 hover:file:bg-amber-400"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!uploadFile || uploadingDataset}
|
||||
className="flex items-center gap-2 rounded-xl bg-ink-900 hover:bg-ink-950 text-white px-5 py-2.5 text-xs font-bold transition disabled:opacity-40 shrink-0"
|
||||
>
|
||||
{uploadingDataset ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
Train Decision Model
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{datasetResult && (
|
||||
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-5 space-y-4">
|
||||
<div className="flex items-center gap-2 text-emerald-800 font-bold text-sm">
|
||||
<CheckCircle2 className="h-5 w-5" /> {datasetResult.message}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||
<div className="bg-white p-3 rounded-lg border border-emerald-200">
|
||||
<span className="text-slate-500 font-semibold">Total Records:</span>
|
||||
<p className="font-mono text-lg font-bold text-ink-950">{datasetResult.total_records}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-3 rounded-lg border border-emerald-200">
|
||||
<span className="text-slate-500 font-semibold">Training Set (80%):</span>
|
||||
<p className="font-mono text-lg font-bold text-amber-600">{datasetResult.dataset_split.training_records}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-3 rounded-lg border border-emerald-200">
|
||||
<span className="text-slate-500 font-semibold">Testing Set (20%):</span>
|
||||
<p className="font-mono text-lg font-bold text-blue-600">{datasetResult.dataset_split.testing_records}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: STOCK REMAINING DISCOUNT ALLOCATOR */}
|
||||
{activeTab === 'discounts' && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs space-y-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-1">
|
||||
<Percent className="h-5 w-5 text-amber-500" /> Allocate Product Discounts Based on Stock Remaining
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Help stores optimize business performance and clear remaining stock by allocating dynamic discount rules based on inventory levels.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAllocateDiscounts} className="grid grid-cols-1 sm:grid-cols-4 gap-4 bg-slate-50 p-4 rounded-xl border border-ink-900/10">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink-800 mb-1">Min Stock</label>
|
||||
<input
|
||||
type="number"
|
||||
value={minStock}
|
||||
onChange={(e) => setMinStock(e.target.value)}
|
||||
className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink-800 mb-1">Max Stock Remaining</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxStock}
|
||||
onChange={(e) => setMaxStock(e.target.value)}
|
||||
className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink-800 mb-1">Discount %</label>
|
||||
<input
|
||||
type="number"
|
||||
value={discountPct}
|
||||
onChange={(e) => setDiscountPct(e.target.value)}
|
||||
className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={allocating}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl bg-amber-500 hover:bg-amber-400 text-slate-950 py-2.5 text-xs font-bold shadow-sm transition"
|
||||
>
|
||||
{allocating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Percent className="h-4 w-4" />}
|
||||
Allocate Discounts
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{allocationResult && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-xs text-slate-600">
|
||||
<span className="font-bold text-ink-950">Applied Discount Allocations ({allocationResult.total_products_allocated} products)</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto border border-ink-900/10 rounded-xl bg-white divide-y divide-slate-100">
|
||||
{allocationResult.allocations.map((a, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 text-xs">
|
||||
<div>
|
||||
<p className="font-bold text-ink-950">{a.product_name}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono">Store: {a.store_id} · Stock Remaining: {a.stock_remaining}</p>
|
||||
</div>
|
||||
<div className="text-right font-mono">
|
||||
<span className="inline-block bg-emerald-100 text-emerald-800 font-bold px-2 py-0.5 rounded text-[11px] mr-2">
|
||||
{a.discount_pct}% OFF
|
||||
</span>
|
||||
<span className="text-slate-400 line-through mr-1">₹{a.original_price}</span>
|
||||
<span className="font-bold text-leaf-600 text-sm">₹{a.final_price}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 4: INGESTION PIPELINE */}
|
||||
{activeTab === 'ingest' && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs space-y-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-1">
|
||||
<Server className="h-5 w-5 text-amber-500" /> Vector Database Catalog Ingestion
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Discover and store brand products into pgvector (discovery → images → embeddings).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmitIngestion} className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs font-semibold text-ink-800">Brand name</label>
|
||||
<input
|
||||
value={brand}
|
||||
onChange={(e) => setBrand(e.target.value)}
|
||||
placeholder="e.g. Britannia"
|
||||
disabled={!!runningJob}
|
||||
className="w-full rounded-xl border border-ink-900/15 p-2.5 text-xs font-medium focus:border-amber-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<label className="mb-1 block text-xs font-semibold text-ink-800">Max products</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={300}
|
||||
value={maxProducts}
|
||||
onChange={(e) => setMaxProducts(e.target.value)}
|
||||
disabled={!!runningJob}
|
||||
className="w-full rounded-xl border border-ink-900/15 p-2.5 text-xs font-medium focus:border-amber-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !brand.trim() || !!runningJob}
|
||||
className="flex items-center justify-center gap-1.5 rounded-xl bg-ink-900 px-5 py-2.5 text-xs font-bold text-white transition hover:bg-ink-950 disabled:opacity-40"
|
||||
>
|
||||
<PlayCircle className="h-4 w-4" /> Start Ingestion
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{runningJob && (
|
||||
<p className="text-xs text-amber-600">
|
||||
A job for "{runningJob.brand}" is already in progress - please wait.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h3 className="font-display text-xs font-semibold uppercase tracking-wide text-slate-500 mt-6">Recent jobs</h3>
|
||||
{jobs.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">No ingestion jobs started yet this session.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{jobs.map((j) => {
|
||||
const style = STATUS_STYLES[j.status] || STATUS_STYLES.pending;
|
||||
const Icon = style.icon;
|
||||
return (
|
||||
<li key={j.job_id} className="flex items-center gap-3 rounded-xl border border-ink-900/10 bg-white px-4 py-3">
|
||||
<span className={`flex h-8 w-8 items-center justify-center rounded-full ${style.className}`}>
|
||||
<Icon className={`h-4 w-4 ${style.spin ? 'animate-spin' : ''}`} />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-bold text-ink-950">{j.brand}</p>
|
||||
<p className="text-[11px] text-slate-500">{j.detail || j.status}</p>
|
||||
</div>
|
||||
<span className="font-mono text-xs uppercase text-slate-400">{j.status}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
143
frontend/src/pages/LoginPage.jsx
Normal file
143
frontend/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { ShieldCheck, UserCheck, Lock, User, ArrowRight, Sparkles } from 'lucide-react';
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
|
||||
const [selectedRole, setSelectedRole] = useState('admin');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleRoleSelect = (role) => {
|
||||
setSelectedRole(role);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (!username.trim() || !password.trim()) {
|
||||
setError('Please enter both username and password.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const user = await login(username, password, selectedRole);
|
||||
setLoading(false);
|
||||
if (user.role === 'admin') navigate('/admin');
|
||||
else navigate('/user');
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
setError(`Login failed. Invalid username or password for ${selectedRole.toUpperCase()} account.`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-slate-950 text-slate-100 flex items-center justify-center p-4 relative overflow-hidden font-sans">
|
||||
{/* Background Glow Accents */}
|
||||
<div className="absolute -top-40 -left-40 h-96 w-96 rounded-full bg-amber-500/10 blur-3xl pointer-events-none" />
|
||||
<div className="absolute -bottom-40 -right-40 h-96 w-96 rounded-full bg-emerald-500/10 blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="w-full max-w-md relative z-10">
|
||||
{/* Header Branding */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full bg-amber-500/10 border border-amber-500/30 text-amber-400 text-xs font-semibold mb-3">
|
||||
<Sparkles className="h-3.5 w-3.5" /> Brand Catalog & Store Intelligence
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-white font-display">System Authentication</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">Please enter your username and password to sign in</p>
|
||||
</div>
|
||||
|
||||
{/* Main Card */}
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-900/80 p-6 backdrop-blur-xl shadow-2xl">
|
||||
{/* Role Selection Tabs: Admin and User */}
|
||||
<div className="grid grid-cols-2 gap-2 p-1.5 rounded-xl bg-slate-950/80 border border-slate-800/80 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRoleSelect('admin')}
|
||||
className={`flex items-center justify-center gap-2 py-3 px-4 rounded-lg text-xs font-bold transition-all ${
|
||||
selectedRole === 'admin'
|
||||
? 'bg-amber-500 text-slate-950 shadow-md scale-[1.02]'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" /> Admin Account
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRoleSelect('user')}
|
||||
className={`flex items-center justify-center gap-2 py-3 px-4 rounded-lg text-xs font-bold transition-all ${
|
||||
selectedRole === 'user'
|
||||
? 'bg-blue-500 text-white shadow-md scale-[1.02]'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<UserCheck className="h-4 w-4" /> User Account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-rose-500/10 border border-rose-500/30 p-3 text-xs text-rose-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleFormSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-300 mb-1.5">Username</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={`Enter ${selectedRole} username`}
|
||||
className="w-full rounded-xl border border-slate-700 bg-slate-950/80 py-2.5 pl-9 pr-4 text-sm text-white placeholder-slate-500 focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-300 mb-1.5">Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
className="w-full rounded-xl border border-slate-700 bg-slate-950/80 py-2.5 pl-9 pr-4 text-sm text-white placeholder-slate-500 focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-slate-950 py-3 text-sm font-bold shadow-lg shadow-amber-500/20 transition-all duration-200 active:scale-98 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Authenticating...' : `Sign In as ${selectedRole.toUpperCase()}`}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer info */}
|
||||
<p className="text-center text-xs text-slate-500 mt-6">
|
||||
Powered by pgvector RAG Model & Multi-Store Nutrition Intelligence Engine
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -160,7 +160,7 @@ export function StoresPage() {
|
||||
<tr key={`${p.brand}-${p.image_id}`} className="hover:bg-amber-50/40">
|
||||
<td className="px-4 py-2.5">
|
||||
<p className="font-medium text-ink-900">{p.title}</p>
|
||||
<p className="text-xs text-slate-400">{p.brand}</p>
|
||||
<p className="text-xs text-slate-400">{p.brand}{p.fssai_license ? ` · FSSAI: ${p.fssai_license}` : ''}</p>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-slate-500">{p.category}</td>
|
||||
<td className="px-4 py-2.5 font-mono">{p.available_stock}</td>
|
||||
|
||||
1004
frontend/src/pages/UserPage.jsx
Normal file
1004
frontend/src/pages/UserPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,13 +8,13 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// Avoid CORS entirely in dev by proxying /api/* straight to FastAPI.
|
||||
// Adjust the target if your backend runs on a different host/port.
|
||||
// Proxy all /api/* requests straight to FastAPI on port 8000
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
},
|
||||
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -8,14 +8,17 @@ Usage:
|
||||
What this script automates:
|
||||
1. Checks & launches PostgreSQL via Docker Compose (`docker compose up -d`).
|
||||
2. Starts FastAPI backend server on http://localhost:8000 (with background auto-seeding).
|
||||
3. Starts Vite frontend dev server on http://localhost:5173 (or serves static dist).
|
||||
4. Keeps both servers running concurrently with instant hot-reloading & single Ctrl+C exit.
|
||||
3. Waits until backend port 8000 is live & ready to receive connections.
|
||||
4. Starts Vite frontend dev server on http://localhost:5173.
|
||||
5. Keeps both servers running concurrently with instant hot-reloading & single Ctrl+C exit.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
import signal
|
||||
import shutil
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
if sys.platform == "win32":
|
||||
@@ -41,28 +44,71 @@ def print_banner():
|
||||
print("=" * 70 + "\033[0m")
|
||||
|
||||
|
||||
import shutil
|
||||
|
||||
def start_docker():
|
||||
print("\n📦 Checking PostgreSQL Docker container...")
|
||||
compose_file = ROOT_DIR / "docker-compose.yml"
|
||||
if not compose_file.exists():
|
||||
compose_file = BACKEND_DIR / "docker-compose.yml"
|
||||
|
||||
if not shutil.which("docker"):
|
||||
print("ℹ️ Docker CLI not found. Skipping container management.")
|
||||
return
|
||||
|
||||
# 1. Check if container is already running
|
||||
try:
|
||||
check_running = subprocess.run(
|
||||
["docker", "ps", "--filter", "name=catalog_rag_postgres", "--format", "{{.Names}}"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if "catalog_rag_postgres" in check_running.stdout:
|
||||
print("✅ PostgreSQL container 'catalog_rag_postgres' is running on port 5432 (pgvector ready).")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Check if stopped container exists and start it
|
||||
try:
|
||||
check_all = subprocess.run(
|
||||
["docker", "ps", "-a", "--filter", "name=catalog_rag_postgres", "--format", "{{.Names}}"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if "catalog_rag_postgres" in check_all.stdout:
|
||||
print("🔄 Starting existing 'catalog_rag_postgres' container...")
|
||||
start_res = subprocess.run(["docker", "start", "catalog_rag_postgres"], capture_output=True, text=True)
|
||||
if start_res.returncode == 0:
|
||||
print("✅ PostgreSQL container started on port 5432 (pgvector ready).")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Fallback to Docker Compose
|
||||
if compose_file.exists():
|
||||
try:
|
||||
res = subprocess.run(["docker", "compose", "-f", str(compose_file), "up", "-d"], capture_output=True, text=True)
|
||||
res = subprocess.run(
|
||||
["docker", "compose", "-f", str(compose_file), "up", "-d", "--remove-orphans"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if res.returncode == 0:
|
||||
print("✅ PostgreSQL container is running on port 5432 (pgvector ready).")
|
||||
else:
|
||||
print("⚠️ Docker notice:", res.stderr.strip() or "Ensure Docker Desktop is running if using Docker Postgres.")
|
||||
stderr_msg = res.stderr.strip()
|
||||
if "Conflict" in stderr_msg or "already in use" in stderr_msg:
|
||||
print("⚠️ Container name conflict detected. Automatically resetting container...")
|
||||
subprocess.run(["docker", "rm", "-f", "catalog_rag_postgres"], capture_output=True, text=True)
|
||||
retry_res = subprocess.run(
|
||||
["docker", "compose", "-f", str(compose_file), "up", "-d"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if retry_res.returncode == 0:
|
||||
print("✅ PostgreSQL container successfully recreated on port 5432 (pgvector ready).")
|
||||
return
|
||||
print("⚠️ Docker notice:", stderr_msg or "Ensure Docker Desktop is running if using Docker Postgres.")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Docker check skipped: {e}")
|
||||
|
||||
|
||||
def start_ollama():
|
||||
print("\n🦙 Checking Ollama LLM service...")
|
||||
import urllib.request
|
||||
try:
|
||||
req = urllib.request.urlopen("http://localhost:11434/api/tags", timeout=2)
|
||||
if req.getcode() == 200:
|
||||
@@ -84,6 +130,19 @@ def start_ollama():
|
||||
return None
|
||||
|
||||
|
||||
def wait_for_backend_ready():
|
||||
print("⏳ Waiting for backend API to initialize on port 8000...")
|
||||
for _ in range(30):
|
||||
try:
|
||||
req = urllib.request.urlopen("http://127.0.0.1:8000/api/system/status", timeout=1)
|
||||
if req.getcode() == 200:
|
||||
print("✅ Backend API is live and accepting connections on port 8000.")
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
print_banner()
|
||||
start_docker()
|
||||
@@ -109,7 +168,10 @@ def main():
|
||||
)
|
||||
processes.append(p_backend)
|
||||
|
||||
# 2. Start Frontend Vite Dev Server
|
||||
# 2. Wait until Backend is ready before starting Vite frontend
|
||||
wait_for_backend_ready()
|
||||
|
||||
# 3. Start Frontend Vite Dev Server
|
||||
print("🎨 Starting Frontend Vite Server (http://localhost:5173)...")
|
||||
npm_cmd = "npm.cmd" if sys.platform == "win32" else "npm"
|
||||
p_frontend = subprocess.Popen(
|
||||
@@ -127,7 +189,7 @@ def main():
|
||||
print("=" * 70)
|
||||
print(" (Press Ctrl+C to stop all servers gracefully)\n")
|
||||
|
||||
# Monitor core servers (backend & frontend)
|
||||
# Monitor core processes (backend & frontend)
|
||||
core_processes = [p_backend, p_frontend]
|
||||
while True:
|
||||
time.sleep(1)
|
||||
|
||||
Reference in New Issue
Block a user