Commit 5ac1c275 authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

feat: improve stylist engine diversity with round-robin and add raw json description viewer

parent 62f1d65f
...@@ -74,7 +74,7 @@ def init_db() -> None: ...@@ -74,7 +74,7 @@ def init_db() -> None:
reviewed_by TEXT, reviewed_by TEXT,
reviewed_at TIMESTAMP, reviewed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (feedback_id) REFERENCES feedbacks(id) FOREIGN KEY (feedback_id) REFERENCES feedbacks(feedback_id)
) )
""" """
) )
...@@ -133,10 +133,10 @@ def init_db() -> None: ...@@ -133,10 +133,10 @@ def init_db() -> None:
cur.execute("SELECT COUNT(*) FROM event_daily_prompts") cur.execute("SELECT COUNT(*) FROM event_daily_prompts")
if list(cur.fetchone().values())[0] == 0: if list(cur.fetchone().values())[0] == 0:
mock_events = [ mock_events = [
('daily_default', 'Daily Standard', 'Daily', 'Mỗi ngày', 0, '- Tư vấn dựa trên yêu cầu cơ bản của khách hàng.\n- Tập trung vào tính tiện dụng và sự thoải mái.\n- Ưu tiên các sản phẩm mới nhất hoặc best seller nếu khách không có yêu cầu đặc biệt.'), ('daily_default', 'Daily Standard', 'Daily', 'Mỗi ngày', False, '- Tư vấn dựa trên yêu cầu cơ bản của khách hàng.\n- Tập trung vào tính tiện dụng và sự thoải mái.\n- Ưu tiên các sản phẩm mới nhất hoặc best seller nếu khách không có yêu cầu đặc biệt.'),
('womens_day', 'Quốc Tế Phụ Nữ 8/3', 'Campaign', '08/03/2026', 1, '- Ưu tiên gợi ý các sản phẩm váy đầm, đồ đôi, hoặc quà tặng cho phái nữ.\n- Gợi ý các combo quà tặng có giá trị cao.\n- Tone giọng: Nhẹ nhàng, tôn vinh phái đẹp, kèm lời chúc mừng 8/3.\n- Báo cho khách biết đang có chương trình giảm giá 30% cho đồ nữ.'), ('womens_day', 'Quốc Tế Phụ Nữ 8/3', 'Campaign', '08/03/2026', True, '- Ưu tiên gợi ý các sản phẩm váy đầm, đồ đôi, hoặc quà tặng cho phái nữ.\n- Gợi ý các combo quà tặng có giá trị cao.\n- Tone giọng: Nhẹ nhàng, tôn vinh phái đẹp, kèm lời chúc mừng 8/3.\n- Báo cho khách biết đang có chương trình giảm giá 30% cho đồ nữ.'),
('summer_sale', 'Đón Hè Sôi Động', 'Campaign', '01/05/2026 - 31/05/2026', 0, '- Tuyệt đối không gợi ý áo khoác dày, áo len.\n- Chỉ tập trung vào áo phông, đồ bơi, quần short, chất liệu thoáng mát (cotton, lanh).\n- Gợi ý đồ mặc đi biển, đi chơi ngày hè.'), ('summer_sale', 'Đón Hè Sôi Động', 'Campaign', '01/05/2026 - 31/05/2026', False, '- Tuyệt đối không gợi ý áo khoác dày, áo len.\n- Chỉ tập trung vào áo phông, đồ bơi, quần short, chất liệu thoáng mát (cotton, lanh).\n- Gợi ý đồ mặc đi biển, đi chơi ngày hè.'),
('black_friday', 'Black Friday Siêu Sale', 'Campaign', '27/11/2026', 0, '- NHẤN MẠNH vào giá sale cực sốc.\n- Up-sale mạnh tay, gợi ý mua theo combo để được giảm thêm.\n- Tạo cảm giác khan hiếm: \'Số lượng có hạn\', \'Sale lớn nhất năm\'.') ('black_friday', 'Black Friday Siêu Sale', 'Campaign', '27/11/2026', False, '- NHẤN MẠNH vào giá sale cực sốc.\n- Up-sale mạnh tay, gợi ý mua theo combo để được giảm thêm.\n- Tạo cảm giác khan hiếm: \'Số lượng có hạn\', \'Sale lớn nhất năm\'.')
] ]
cur.executemany( cur.executemany(
""" """
...@@ -148,7 +148,7 @@ def init_db() -> None: ...@@ -148,7 +148,7 @@ def init_db() -> None:
conn.commit() conn.commit()
conn.close() conn.close()
logger.info("Auto-Train DB initialized at %s", DB_PATH) logger.info("Auto-Train DB initialized successfully.")
try: try:
......
...@@ -15,6 +15,7 @@ from config import CHECKPOINT_POSTGRES_URL ...@@ -15,6 +15,7 @@ from config import CHECKPOINT_POSTGRES_URL
VN_TZ = timezone(timedelta(hours=7)) VN_TZ = timezone(timedelta(hours=7))
_pool: AsyncConnectionPool | None = None _pool: AsyncConnectionPool | None = None
logger = logging.getLogger(__name__)
def _ensure_table(): def _ensure_table():
try: try:
...@@ -56,7 +57,6 @@ async def _get_pool() -> AsyncConnectionPool: ...@@ -56,7 +57,6 @@ async def _get_pool() -> AsyncConnectionPool:
def _now() -> datetime: def _now() -> datetime:
return datetime.now(VN_TZ) return datetime.now(VN_TZ)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/dashboard", tags=["Experiment Log"]) router = APIRouter(prefix="/api/dashboard", tags=["Experiment Log"])
......
""" """
Manage Ultra Description — API Route Manage Ultra Description — API Route
Browse, inspect, and audit the PostgreSQL product catalog table Browse, inspect, and audit the PostgreSQL product catalog table
(public.sr__test_db__magento_product_dimension_with_text_embedding). (con_meo.magento_product_dimension_with_text_embedding).
Features: Features:
- List products with pagination & null-field filtering - List products with pagination & null-field filtering
- View raw JSON detail for any product - View raw JSON detail for any product
- Stats dashboard: count nulls per column - Stats dashboard: count nulls per column
- Update similar_items and outfit_recommendations
- AI recommendations for similar items and outfits
""" """
import logging import logging
import json
import re
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel
from typing import Optional, Any
from psycopg.rows import dict_row from psycopg.rows import dict_row
from common.db_pool import db_pool from common.db_pool import db_pool
...@@ -19,7 +25,7 @@ from common.db_pool import db_pool ...@@ -19,7 +25,7 @@ from common.db_pool import db_pool
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/manage-ultra-desc", tags=["Manage Ultra Description"]) router = APIRouter(prefix="/api/manage-ultra-desc", tags=["Manage Ultra Description"])
TABLE = "public.sr__test_db__magento_product_dimension_with_text_embedding" TABLE = "con_meo.magento_product_dimension_with_text_embedding"
# Columns we care about for null-checking & display # Columns we care about for null-checking & display
IMPORTANT_COLS = [ IMPORTANT_COLS = [
...@@ -51,6 +57,43 @@ ALL_COLS = [ ...@@ -51,6 +57,43 @@ ALL_COLS = [
] ]
class UpdateRecommendationsPayload(BaseModel):
similar_items: Optional[Any] = None
outfit_recommendations: Optional[Any] = None
def _extract_base_sku(sku: str) -> str:
"""Extract base SKU by stripping color suffix.
'6TS26A002-SK010' -> '6TS26A002'
'8TH25W001-CG100' -> '8TH25W001'
'8TH25W008' -> '8TH25W008'
"""
m = re.match(r'^(.+?)-[A-Z]{1,2}\d{3,4}$', sku)
return m.group(1) if m else sku
@router.get("/color-variants", summary="Find all color variants of a product")
def get_color_variants(sku: str = Query(..., description="Any SKU of the product")):
"""Given a SKU, find all color variants sharing the same base SKU."""
try:
base = _extract_base_sku(sku)
query = f"""
SELECT magento_ref_code, COALESCE(product_color_code, magento_ref_code) AS product_color_code, product_color_name,
product_image_url_thumbnail, product_name
FROM {TABLE}
WHERE magento_ref_code LIKE %s
ORDER BY magento_ref_code
"""
with db_pool.get_conn() as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(query, [f"{base}%"])
rows = cur.fetchall()
return {"status": "success", "base_sku": base, "variants": rows}
except Exception as e:
logger.error(f"Color variants error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.get("/stats", summary="Overview statistics — null counts per column") @router.get("/stats", summary="Overview statistics — null counts per column")
def get_stats(): def get_stats():
"""Return total rows + null count for each important column.""" """Return total rows + null count for each important column."""
...@@ -91,7 +134,7 @@ def list_products( ...@@ -91,7 +134,7 @@ def list_products(
limit: int = Query(50, ge=1, le=200), limit: int = Query(50, ge=1, le=200),
null_field: str = Query(None, description="Filter products where this column IS NULL"), null_field: str = Query(None, description="Filter products where this column IS NULL"),
filled_field: str = Query(None, description="Filter products where this column IS NOT NULL"), filled_field: str = Query(None, description="Filter products where this column IS NOT NULL"),
search: str = Query(None, description="Search by product_name or magento_ref_code"), search: str = Query(None, description="Search by product_name, magento_ref_code or internal_ref_code"),
): ):
"""Paginated product list with null/filled filtering.""" """Paginated product list with null/filled filtering."""
offset = (page - 1) * limit offset = (page - 1) * limit
...@@ -104,23 +147,28 @@ def list_products( ...@@ -104,23 +147,28 @@ def list_products(
if filled_field and filled_field in ALL_COLS: if filled_field and filled_field in ALL_COLS:
where_clauses.append(f"({filled_field} IS NOT NULL AND CAST({filled_field} AS TEXT) != '')") where_clauses.append(f"({filled_field} IS NOT NULL AND CAST({filled_field} AS TEXT) != '')")
if search: if search:
where_clauses.append("(product_name ILIKE %s OR magento_ref_code ILIKE %s)") where_clauses.append("(product_name ILIKE %s OR magento_ref_code ILIKE %s OR internal_ref_code ILIKE %s)")
params.extend([f"%{search}%", f"%{search}%"]) params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
count_q = f"SELECT COUNT(*) AS cnt FROM {TABLE} {where_sql}" count_q = f"SELECT COUNT(DISTINCT magento_ref_code) AS cnt FROM {TABLE} {where_sql}"
select_cols = ( select_cols = (
"magento_ref_code, internal_ref_code, product_name, product_color_name, " "magento_ref_code, internal_ref_code, product_name, product_color_name, "
"master_color, product_line_vn, gender_by_product, sale_price, original_price, " "master_color, product_line_vn, gender_by_product, sale_price, original_price, "
"quantity_sold, product_image_url_thumbnail, tags, similar_items, " "quantity_sold, product_image_url_thumbnail, "
"description_text_full_save" "MAX(CASE WHEN similar_items IS NOT NULL AND similar_items != '[]' AND similar_items != '' THEN 1 ELSE 0 END) OVER (PARTITION BY magento_ref_code) = 1 AS has_sim, "
"MAX(CASE WHEN outfit_recommendations IS NOT NULL AND outfit_recommendations != '[]' AND outfit_recommendations != '' THEN 1 ELSE 0 END) OVER (PARTITION BY magento_ref_code) = 1 AS has_out"
) )
data_q = f""" data_q = f"""
SELECT {select_cols} WITH ranked AS (
FROM {TABLE} SELECT {select_cols},
{where_sql} ROW_NUMBER() OVER (PARTITION BY magento_ref_code ORDER BY quantity_sold DESC NULLS LAST) as rn
FROM {TABLE}
{where_sql}
)
SELECT * FROM ranked WHERE rn = 1
ORDER BY quantity_sold DESC NULLS LAST ORDER BY quantity_sold DESC NULLS LAST
LIMIT {limit} OFFSET {offset} LIMIT {limit} OFFSET {offset}
""" """
...@@ -136,15 +184,16 @@ def list_products( ...@@ -136,15 +184,16 @@ def list_products(
cursor.execute(data_q, params) cursor.execute(data_q, params)
rows = cursor.fetchall() rows = cursor.fetchall()
# Truncate long fields for list view # Format items for sidebar list view
products = [] products = []
for r in rows: for r in rows:
p = dict(r) p = dict(r)
# Indicate presence rather than full content p["has_sim"] = bool(r.get("has_sim"))
for long_col in ["description_text_full_save", "tags", "similar_items"]: p["has_out"] = bool(r.get("has_out"))
val = p.get(long_col) p["similar_items"] = "[has_items]" if p["has_sim"] else "[]"
if val and len(str(val)) > 80: p["outfit_recommendations"] = "[has_items]" if p["has_out"] else "[]"
p[long_col] = str(val)[:80] + "..." p["description_text_full_save"] = ""
p["tags"] = ""
products.append(p) products.append(p)
return { return {
...@@ -161,26 +210,277 @@ def list_products( ...@@ -161,26 +210,277 @@ def list_products(
@router.get("/detail/{code}", summary="Full raw detail for a product") @router.get("/detail/{code}", summary="Full raw detail for a product")
def get_detail(code: str): def get_detail(code: str):
"""Return all columns for a single product (by magento_ref_code).""" """Return all columns for a single product (by magento_ref_code or internal_ref_code)."""
try: try:
query = f"SELECT * FROM {TABLE} WHERE magento_ref_code = %s LIMIT 1" query = f"""
SELECT * FROM {TABLE}
WHERE magento_ref_code = %s OR internal_ref_code = %s OR product_color_code = %s
ORDER BY quantity_sold DESC NULLS LAST
LIMIT 1
"""
with db_pool.get_conn() as conn: with db_pool.get_conn() as conn:
with conn.cursor(row_factory=dict_row) as cursor: with conn.cursor(row_factory=dict_row) as cursor:
cursor.execute(query, (code,)) cursor.execute(query, (code, code, code))
row = cursor.fetchone() row = cursor.fetchone()
if not row: if not row:
return JSONResponse(status_code=404, content={"status": "error", "message": "Not found"}) return JSONResponse(status_code=404, content={"status": "error", "message": f"Product {code} not found"})
product = dict(row) product = dict(row)
# Remove vector if exists in DB (though in new PG schema it's omitted)
product.pop("vector", None) product.pop("vector", None)
# Parse description_data_for_production
desc_data = {}
if product.get("description_data_for_production"):
try:
desc_data = json.loads(product["description_data_for_production"])
except Exception:
pass
product["parsed_description_data"] = desc_data
# Parse similar_items
similar = []
if product.get("similar_items"):
try:
similar = json.loads(product["similar_items"])
except Exception:
pass
product["parsed_similar_items"] = similar
# Parse outfit_recommendations
outfits = []
if product.get("outfit_recommendations"):
try:
outfits = json.loads(product["outfit_recommendations"])
except Exception:
pass
product["parsed_outfit_recommendations"] = outfits
return {"status": "success", "product": product} return {"status": "success", "product": product}
except Exception as e: except Exception as e:
logger.error(f"Detail error: {e}") logger.error(f"Detail error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)}) return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.put("/update/{code}", summary="Update similar_items and outfit_recommendations")
def update_recommendations(code: str, payload: UpdateRecommendationsPayload):
"""Update similar items and outfit recommendations in the database."""
try:
sim_str = None
if payload.similar_items is not None:
if isinstance(payload.similar_items, str):
sim_str = payload.similar_items
else:
sim_str = json.dumps(payload.similar_items, ensure_ascii=False)
outfit_str = None
if payload.outfit_recommendations is not None:
if isinstance(payload.outfit_recommendations, str):
outfit_str = payload.outfit_recommendations
else:
outfit_str = json.dumps(payload.outfit_recommendations, ensure_ascii=False)
query = f"""
UPDATE {TABLE}
SET similar_items = %s, outfit_recommendations = %s
WHERE magento_ref_code = %s OR internal_ref_code = %s OR product_color_code = %s
"""
with db_pool.get_conn() as conn:
with conn.cursor() as cursor:
cursor.execute(query, (sim_str, outfit_str, code, code, code))
return {"status": "success", "message": f"Updated recommendations for {code}"}
except Exception as e:
logger.error(f"Update error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/ai-suggest-similar/{code}", summary="Suggest similar items using vector distance or attributes")
def ai_suggest_similar(code: str):
"""Find and suggest top similar products using vector database search or attribute matches."""
try:
# First try vector matching from the test schema table
vector_matches = []
try:
query = """
SELECT magento_ref_code, product_name, product_image_url_thumbnail, product_color_name, internal_ref_code
FROM test.magento_product_dimension_with_text_embedding
WHERE vector IS NOT NULL
AND magento_ref_code != %s
AND internal_ref_code != %s
AND (product_color_code IS NULL OR product_color_code != %s)
ORDER BY vector <=> (
SELECT vector
FROM test.magento_product_dimension_with_text_embedding
WHERE magento_ref_code = %s OR internal_ref_code = %s OR product_color_code = %s
LIMIT 1
)
LIMIT 10
"""
with db_pool.get_conn() as conn:
with conn.cursor(row_factory=dict_row) as cursor:
cursor.execute(query, (code, code, code, code, code, code))
vector_matches = cursor.fetchall()
except Exception as vex:
logger.warning(f"Vector similarity query bypassed or failed: {vex}")
if vector_matches:
results = []
for r in vector_matches:
results.append({
"sku": r.get("internal_ref_code") or r.get("magento_ref_code"),
"sku_color": r.get("internal_ref_code") or r.get("magento_ref_code"),
"color_name": r.get("product_color_name") or "",
"magento_ref_code": r.get("magento_ref_code"),
"product_name": r.get("product_name"),
"product_image_url_thumbnail": r.get("product_image_url_thumbnail")
})
return {"status": "success", "source": "vector", "suggestions": results}
# Fallback: attribute matching
with db_pool.get_conn() as conn:
with conn.cursor(row_factory=dict_row) as cursor:
cursor.execute(f"SELECT gender_by_product, age_by_product, product_group FROM {TABLE} WHERE magento_ref_code = %s OR internal_ref_code = %s OR product_color_code = %s LIMIT 1", (code, code, code))
orig = cursor.fetchone()
if not orig:
return {"status": "success", "source": "none", "suggestions": []}
gender = orig.get("gender_by_product")
age = orig.get("age_by_product")
group = orig.get("product_group")
fallback_q = f"""
SELECT magento_ref_code, product_name, product_image_url_thumbnail, product_color_name, internal_ref_code
FROM {TABLE}
WHERE (magento_ref_code != %s AND internal_ref_code != %s AND (product_color_code IS NULL OR product_color_code != %s))
AND gender_by_product = %s
AND age_by_product = %s
AND product_group = %s
ORDER BY quantity_sold DESC NULLS LAST
LIMIT 10
"""
with db_pool.get_conn() as conn:
with conn.cursor(row_factory=dict_row) as cursor:
cursor.execute(fallback_q, (code, code, code, gender, age, group))
rows = cursor.fetchall()
results = []
for r in rows:
results.append({
"sku": r.get("internal_ref_code") or r.get("magento_ref_code"),
"sku_color": r.get("internal_ref_code") or r.get("magento_ref_code"),
"color_name": r.get("product_color_name") or "",
"magento_ref_code": r.get("magento_ref_code"),
"product_name": r.get("product_name"),
"product_image_url_thumbnail": r.get("product_image_url_thumbnail")
})
return {"status": "success", "source": "attribute", "suggestions": results}
except Exception as e:
logger.error(f"Suggest similar error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/ai-suggest-outfits/{code}", summary="Suggest outfits based on cross-sell text")
def ai_suggest_outfits(code: str):
"""Find outfits that match the product's cross_sell keywords and gender/age constraints."""
try:
from worker.stylist_engine import StylistEngine
engine = StylistEngine()
with db_pool.get_conn() as conn:
with conn.cursor(row_factory=dict_row) as cursor:
cursor.execute(f"SELECT product_name, product_color_name, master_color, description_data_for_production, gender_by_product, age_by_product FROM {TABLE} WHERE magento_ref_code = %s OR internal_ref_code = %s OR product_color_code = %s LIMIT 1", (code, code, code))
orig = cursor.fetchone()
if not orig:
return {"status": "success", "suggestions": []}
src_name = orig.get("product_name") or ""
src_color = orig.get("product_color_name") or ""
src_master_color = orig.get("master_color") or ""
desc_json = orig.get("description_data_for_production")
gender = orig.get("gender_by_product")
age = orig.get("age_by_product")
src_dict = {
"name": src_name,
"color": src_color or src_master_color,
"product_line": "Quần" if any(w in src_name.lower() for w in ["quần", "váy", "short"]) else "Áo"
}
cross_sell_terms = []
if desc_json:
try:
data = json.loads(desc_json)
cs = data.get("cross_sell")
if cs:
cross_sell_terms = [t.strip() for t in cs.split(",") if t.strip()]
except Exception:
pass
if not cross_sell_terms:
cross_sell_terms = ["Quần jean", "Áo khoác", "Chân váy", "Giày sneaker"]
suggestions = []
with db_pool.get_conn() as conn:
with conn.cursor(row_factory=dict_row) as cursor:
for term in cross_sell_terms:
term_lower = term.lower()
role = "top"
if any(x in term_lower for x in ["quần", "váy", "jean", "short", "khaki", "legging", "pant", "chân váy"]):
role = "bottom"
elif any(x in term_lower for x in ["khoác", "blazer", "cardigan", "jacket", "gilet"]):
role = "outerwear"
elif any(x in term_lower for x in ["giày", "sneaker", "thắt lưng", "tất", "mũ", "kính", "accessory", "phụ kiện"]):
role = "accessory"
# Find matching items
query = f"""
SELECT magento_ref_code, product_name, product_color_name, master_color, internal_ref_code, product_image_url_thumbnail
FROM {TABLE}
WHERE (magento_ref_code != %s AND internal_ref_code != %s AND (product_color_code IS NULL OR product_color_code != %s))
AND (product_name ILIKE %s OR product_group ILIKE %s)
AND gender_by_product = %s
AND age_by_product = %s
ORDER BY quantity_sold DESC NULLS LAST
LIMIT 3
"""
cursor.execute(query, (code, code, code, f"%{term}%", f"%{term}%", gender, age))
matches = cursor.fetchall()
for m in matches:
m_name = m.get("product_name")
m_color = m.get("product_color_name") or "nhiều màu"
m_master_color = m.get("master_color") or ""
sku = m.get("internal_ref_code") or m.get("magento_ref_code")
tgt_dict = {
"name": m_name,
"color": m_color or m_master_color,
"product_line": "Áo" if role == "top" else "Quần"
}
# Calculate color score and build dynamic Vietnamese reason
color_score = engine._color_score(src_master_color, m_master_color, gender)
ai_reason = engine._build_reason(src_dict, tgt_dict, occasion="mac_nha", score=int(color_score * 3.33))
suggestions.append({
"sku": sku,
"sku_color": sku,
"color_name": m_color,
"match_product_name": m_name,
"role": role,
"reason": ai_reason,
"product_image_url_thumbnail": m.get("product_image_url_thumbnail")
})
return {"status": "success", "suggestions": suggestions}
except Exception as e:
logger.error(f"Suggest outfits error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.get("/columns", summary="List all column names") @router.get("/columns", summary="List all column names")
def get_columns(): def get_columns():
"""Return list of all columns in the table.""" """Return list of all columns in the table."""
......
...@@ -19,10 +19,10 @@ _BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ...@@ -19,10 +19,10 @@ _BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# ═══════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════
# POSTGRES / STARROCKS TABLE NAMES # POSTGRES / STARROCKS TABLE NAMES
# ═══════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════
TABLE_OUTFIT_RULES = "pg__dashboard_canifa__chatbot_fashion_rules" TABLE_OUTFIT_RULES = "con_meo.chatbot_fashion_rules"
TABLE_FASHION_RULES = "pg__dashboard_canifa__chatbot_fashion_rules" TABLE_FASHION_RULES = "con_meo.chatbot_fashion_rules"
TABLE_OUTFIT_MATCHES = "pg__dashboard_canifa__ai_outfit_product_matches" TABLE_OUTFIT_MATCHES = "con_meo.ai_outfit_product_matches"
TABLE_PRODUCTS = "sr__test_db__magento_product_dimension_with_text_embedding" TABLE_PRODUCTS = "con_meo.magento_product_dimension_with_text_embedding"
# ═══════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════
# GENDER MAPPING (Vietnamese / English → DB key) # GENDER MAPPING (Vietnamese / English → DB key)
......
...@@ -12,6 +12,14 @@ class PooledConnectionWrapper: ...@@ -12,6 +12,14 @@ class PooledConnectionWrapper:
self._conn = conn self._conn = conn
self._closed = False self._closed = False
def cursor(self, *args, **kwargs):
if "cursor_factory" in kwargs:
factory = kwargs.pop("cursor_factory")
if getattr(factory, "__name__", None) in ("RealDictCursor", "DictCursor"):
from psycopg.rows import dict_row
kwargs["row_factory"] = dict_row
return self._conn.cursor(*args, **kwargs)
def __getattr__(self, name: str) -> Any: def __getattr__(self, name: str) -> Any:
return getattr(self._conn, name) return getattr(self._conn, name)
......
import sys
import os
import time
from pathlib import Path
# Add backend directory to Python path
sys.path.insert(0, str(Path(__file__).parent))
from worker.stylist_engine import StylistEngine
from common.db_pool import db_pool
def main():
if sys.platform.startswith('win'):
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
print("="*80)
print("🚀 STARTING STYLIST ENGINE BATCH V2 (COLOR-BASED & RULE-BASED)")
print("="*80)
start_time = time.time()
# Initialize engine
engine = StylistEngine()
# We will run the batch matching by color variants
# This automatically loads catalog with by_color=True, runs matches,
# and double-writes to both tables.
print("[INFO] Loading catalog and executing batch runs...")
catalog = engine._get_catalog(by_color=True)
total_products = len(catalog)
print(f"[INFO] Found {total_products} product color variants in catalog.")
if total_products == 0:
print("[ERROR] Catalog is empty. Exiting.")
return
# Run batch matching
done_count = engine.run_batch(by_color=True)
elapsed = time.time() - start_time
print("="*80)
print(f"🎉 BATCH COMPLETE!")
print(f" Successfully processed: {done_count}/{total_products} items.")
print(f" Total time: {elapsed:.2f} seconds.")
print("="*80)
if __name__ == "__main__":
main()
"""
Script scratch_run_stylist.py - Chạy Stylist Engine hàng loạt cho TOÀN BỘ sản phẩm trong bảng.
Bắt cặp phối chéo màu sắc thông minh, sinh câu lý do thời trang tự nhiên và cập nhật đồng bộ cho cả biến thể con lẫn mã gốc cha.
"""
import os
import sys
import json
from pathlib import Path
import time
# Add backend directory to Python path
sys.path.insert(0, str(Path(__file__).parent))
from common.db_pool import db_pool
from worker.stylist_engine import StylistEngine
def clean_product_name(name: str) -> str:
if not name:
return ""
import re
words_to_strip = [
r"\btrẻ em\b", r"\bbé trai\b", r"\bbé gái\b", r"\bnam\b", r"\bnữ\b",
r"\bcotton\b", r"\bsoft\b", r"\bcool\b", r"\bpremium\b", r"\bdài tay\b",
r"\bngắn tay\b", r"\bsát nách\b", r"\bco giãn\b", r"\bmềm mại\b", r"\bnhẹ nhàng\b"
]
cleaned = name
for pattern in words_to_strip:
cleaned = re.sub(pattern, "", cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
if cleaned:
cleaned = cleaned[0].upper() + cleaned[1:]
return cleaned
def run_batch_stylist_all_products():
if sys.platform.startswith('win'):
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
engine = StylistEngine()
print("\n" + "="*80)
print("🚀 BAT DAU CHAY BATCH STYLIST CHO TOAN BO BANG SAN PHAM")
print("="*80 + "\n")
start_time = time.time()
try:
# 1. Load toàn bộ database sản phẩm vào bộ nhớ để xử lý siêu tốc (tránh truy vấn lặp)
print("[INFO] Dang load toan bo danh muc san pham tu database...")
with db_pool.get_conn() as conn:
with conn.cursor() as cur:
cur.execute("""
SELECT magento_ref_code,
COALESCE(product_color_code, magento_ref_code) as color_code,
product_name,
product_color_name,
master_color,
gender_by_product,
age_by_product,
product_group,
product_image_url_thumbnail,
COALESCE(quantity_sold, 0) as quantity_sold
FROM con_meo.magento_product_dimension_with_text_embedding
WHERE magento_ref_code IS NOT NULL
""")
all_rows = cur.fetchall()
if not all_rows:
print("[ERROR] Khong co du lieu san pham nao trong database!")
return
print(f"[INFO] Da load {len(all_rows)} dong san pham.")
# 2. Phân loại sản phẩm theo magento_ref_code (mã gốc) để xử lý theo nhóm
# Đồng thời xây dựng pool để tìm kiếm sản phẩm phối nhanh chóng
products_by_base = {}
for r in all_rows:
base = r[0]
if base not in products_by_base:
products_by_base[base] = []
products_by_base[base].append({
"base": r[0],
"color_code": r[1],
"name": r[2],
"color_name": r[3],
"master_color": r[4] or "",
"gender": r[5],
"age": r[6],
"group": r[7] or "",
"image": r[8] or "",
"qty": r[9]
})
print(f"[INFO] Tong so ma san pham goc (base code): {len(products_by_base)}")
# 3. Duyệt và tính toán phối đồ cho từng mã gốc
update_data = [] # Lưu các câu lệnh update dạng tuple (outfit_recommendations, color_code)
insert_matches_data = [] # Lưu các câu lệnh insert dạng tuple cho ai_outfit_product_matches
count = 0
for base_code, variants in products_by_base.items():
count += 1
if count % 100 == 0:
print(f"[INFO] Dang phan tich: {count}/{len(products_by_base)} ma san pham goc...")
# Lấy thông tin đặc trưng của mã gốc (dùng variant đầu tiên)
rep = variants[0]
gender = rep["gender"]
age = rep["age"]
group = rep["group"] or ""
# Xác định nhóm đối lập để phối cùng
group_lower = group.lower()
opp_group = "top"
if "top" in group_lower or "áo" in group_lower:
opp_group = "bottom"
elif "bottom" in group_lower or "quần" in group_lower or "váy" in group_lower:
opp_group = "top"
# Tìm danh sách sản phẩm phối phù hợp (cùng giới tính, cùng độ tuổi, đối lập nhóm sản phẩm)
match_pool = []
for other_base, other_variants in products_by_base.items():
if other_base == base_code:
continue
first_other = other_variants[0]
if (first_other["gender"] == gender and
first_other["age"] == age and
(opp_group in (first_other["group"] or "").lower())):
match_pool.extend(other_variants)
# Nếu không tìm thấy nhóm đối lập tương thích, mở rộng tìm kiếm chung
if not match_pool:
for other_base, other_variants in products_by_base.items():
if other_base == base_code:
continue
first_other = other_variants[0]
if (opp_group in (first_other["group"] or "").lower()):
match_pool.extend(other_variants)
if not match_pool:
# Fallback: lay bat ky san pham nao khac nhom
for other_base, other_variants in products_by_base.items():
if other_base == base_code:
continue
match_pool.extend(other_variants)
# Tính toán phối đồ cho từng biến thể của mã gốc
best_variant_recommendations = None
best_variant_qty = -1
for var in variants:
var_color_code = var["color_code"]
var_name = var["name"]
var_color_name = var["color_name"]
var_master_color = var["master_color"]
var_qty = var["qty"]
src_dict = {
"name": var_name,
"color": var_color_name or var_master_color,
"product_line": "Quần mặc nhà" if opp_group == "top" else "Áo mặc nhà"
}
# So khớp màu sắc với danh sách match_pool
scored_matches = []
for candidate in match_pool:
c_color_code = candidate["color_code"]
c_name = candidate["name"]
c_color_name = candidate["color_name"]
c_master_color = candidate["master_color"]
c_image = candidate["image"]
c_qty = candidate["qty"]
color_score = engine._color_score(var_master_color, c_master_color, gender)
tgt_dict = {
"name": c_name,
"color": c_color_name or c_master_color,
"product_line": "Áo mặc nhà" if opp_group == "bottom" else "Quần mặc nhà"
}
ai_reason = engine._build_reason(src_dict, tgt_dict, occasion="mac_nha", score=int(color_score * 3.33))
scored_matches.append({
"sku": c_color_code,
"sku_color": c_color_code,
"color_name": c_color_name,
"match_product_name": c_name,
"role": opp_group,
"reason": ai_reason,
"product_image_url_thumbnail": c_image,
"score": color_score,
"qty": c_qty,
"gender": candidate["gender"],
"age": candidate["age"]
})
# Sắp xếp theo điểm phối màu + số lượng bán
scored_matches.sort(key=lambda x: (x["score"], x["qty"]), reverse=True)
top_matches = scored_matches[:3]
# Tạo JSON danh sách phối đồ
final_recommendations = []
for item in top_matches:
final_recommendations.append({
"sku": item["sku"],
"sku_color": item["sku_color"],
"color_name": item["color_name"],
"match_product_name": item["match_product_name"],
"role": item["role"],
"reason": item["reason"],
"product_image_url_thumbnail": item["product_image_url_thumbnail"]
})
# Thêm vào danh sách insert cho ai_outfit_product_matches
insert_matches_data.append((
9999, # rule_id cho AI Stylist Engine
var_color_code, # magento_ref_code (mã màu của sản phẩm gốc)
item["sku_color"], # match_product_code
item["match_product_name"], # match_product_name
item["gender"], # match_gender
item["age"], # match_age
item["role"], # match_role
float(item["score"] * 10.0), # score (0-100)
item["reason"], # ai_reason
json.dumps(["mac_nha"], ensure_ascii=False) # occasion
))
recommendations_json = json.dumps(final_recommendations, ensure_ascii=False)
# Thêm vào danh sách update biến thể con
update_data.append((recommendations_json, var_color_code))
# 4. Thực thi ghi dữ liệu vào database
# Bước A: Xóa dữ liệu AI cũ trong bảng ai_outfit_product_matches (rule_id = 9999)
print(f"[INFO] Dang xoa du lieu AI cu trong bang con_meo.ai_outfit_product_matches (rule_id = 9999)...")
with db_pool.get_conn() as conn:
with conn.cursor() as cur:
cur.execute("DELETE FROM con_meo.ai_outfit_product_matches WHERE rule_id = 9999")
conn.commit()
# Bước B: Chèn mới các bản ghi match đơn lẻ vào bảng ai_outfit_product_matches
print(f"[INFO] Dang chen {len(insert_matches_data)} dong gợi ý phôi do vao con_meo.ai_outfit_product_matches...")
batch_size = 500
with db_pool.get_conn() as conn:
with conn.cursor() as cur:
for i in range(0, len(insert_matches_data), batch_size):
batch = insert_matches_data[i:i+batch_size]
cur.executemany("""
INSERT INTO con_meo.ai_outfit_product_matches (
rule_id,
magento_ref_code,
match_product_code,
match_product_name,
match_gender,
match_age,
match_role,
score,
ai_reason,
occasion
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", batch)
conn.commit()
# Bước C: Đồng bộ hóa (map đẩy) các JSON recommendations gộp vào bảng magento_product_dimension_with_text_embedding
print(f"[INFO] Dang dong bo {len(update_data)} dong outfit_recommendations vao con_meo.magento_product_dimension_with_text_embedding...")
with db_pool.get_conn() as conn:
with conn.cursor() as cur:
for i in range(0, len(update_data), batch_size):
batch = update_data[i:i+batch_size]
cur.executemany("""
UPDATE con_meo.magento_product_dimension_with_text_embedding
SET outfit_recommendations = %s
WHERE product_color_code = %s
""", batch)
conn.commit()
elapsed = time.time() - start_time
print("\n" + "="*80)
print(f"[SUCCESS] DA HOAN THANH CAP NHAT CHO TOAN BO BANG SAN PHAM!")
print(f" Tong thoi gian chay: {elapsed:.2f} giay.")
print(f" Da insert vao ai_outfit_product_matches: {len(insert_matches_data)} dong.")
print(f" Da update vao magento_product_dimension_with_text_embedding: {len(update_data)} dong.")
print("="*80 + "\n")
except Exception as e:
print(f"[ERROR] Loi trong qua trinh chay batch: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
run_batch_stylist_all_products()
...@@ -156,6 +156,7 @@ ...@@ -156,6 +156,7 @@
<script> <script>
// Chatbot Debug specific logic // Chatbot Debug specific logic
const API_BASE = '/api/chatbot'; const API_BASE = '/api/chatbot';
const NO_IMAGE_SVG = `data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="130" viewBox="0 0 100 130"><rect width="100" height="130" rx="6" fill="%23E2E0D8"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="10" font-weight="600" fill="%238C8A82">NO IMAGE</text></svg>`;
// State // State
let conversationHistory = []; let conversationHistory = [];
...@@ -290,7 +291,7 @@ function updateDebugPanel(data) { ...@@ -290,7 +291,7 @@ function updateDebugPanel(data) {
const list = document.getElementById('productsList'); const list = document.getElementById('productsList');
list.innerHTML = data.products.map(p => ` list.innerHTML = data.products.map(p => `
<div class="product-card"> <div class="product-card">
<img src="${p.image || '/static/placeholder.png'}" alt="${p.name}" onerror="this.src='/static/placeholder.png'"> <img src="${p.image || NO_IMAGE_SVG}" alt="${p.name}" onerror="this.onerror=null; this.src=NO_IMAGE_SVG;">
<div class="product-info"> <div class="product-info">
<div class="product-name">${p.name}</div> <div class="product-name">${p.name}</div>
<div class="product-sku">${p.sku}</div> <div class="product-sku">${p.sku}</div>
......
...@@ -2,448 +2,1670 @@ ...@@ -2,448 +2,1670 @@
<html lang="vi"> <html lang="vi">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Manage Ultra Description</title> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quản lý Catalog - Canifa AI Platform</title>
<link rel="stylesheet" href="/static/common/theme.css"> <link rel="stylesheet" href="/static/common/theme.css">
<link rel="stylesheet" href="/static/common/components.css"> <link rel="stylesheet" href="/static/common/components.css">
<link rel="stylesheet" href="/static/common/iframe-pages.css">
<link rel="stylesheet" href="/static/common/lab.css">
<link rel="stylesheet" href="/static/manage-ultra-desc/json-viewer.css">
<script src="/static/common/frame-detect.js"></script> <script src="/static/common/frame-detect.js"></script>
<script src="/static/manage-ultra-desc/json-viewer.js"></script>
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
<style> <style>
*{box-sizing:border-box} /* ═══ THEME BRIDGE & OVERRIDES ═══ */
body{background:var(--bg);margin:0;color:var(--t);font-family:'Inter',sans-serif;display:flex;height:100vh;overflow:hidden} :root {
--primary: #3b5998;
/* ═══ LAYOUT ═══ */ --primary-rgb: 59, 89, 152;
.app{display:flex;width:100%;height:100%} --primary-hover: #2d4a7a;
.left-panel{width:420px;border-right:1px solid var(--border);display:flex;flex-direction:column;background:var(--card)} --primary-light: #eef1f8;
.right-panel{flex:1;display:flex;flex-direction:column;overflow-y:auto;background:var(--bg)}
--success: #16a34a;
/* ═══ LEFT PANEL ═══ */ --success-light: #ecfdf5;
.left-header{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:10px} --warning: #ca8a04;
.left-title{font-weight:700;font-size:15px;display:flex;align-items:center;gap:8px} --warning-light: #fefce8;
.filter-row{display:flex;gap:6px;align-items:center;flex-wrap:wrap} --danger: #dc2626;
.filter-row select,.filter-row input{padding:6px 10px;border:1px solid var(--border);border-radius:6px;font-size:12px;background:var(--bg);color:var(--t);outline:none} --danger-light: #fef2f2;
.filter-row select:focus,.filter-row input:focus{border-color:var(--primary)} }
.filter-row select{max-width:150px}
.filter-row input{flex:1;min-width:100px} body {
background: var(--bg);
.product-list{flex:1;overflow-y:auto;padding:8px} color: var(--t);
.product-list::-webkit-scrollbar{width:5px} font-family: 'Outfit', sans-serif;
.product-list::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px} margin: 0;
padding: 0;
.p-item{display:flex;gap:10px;padding:10px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;cursor:pointer;transition:all .18s;align-items:center;background:var(--card)} height: 100vh;
.p-item:hover{border-color:var(--primary);transform:translateY(-1px);box-shadow:0 3px 10px rgba(0,0,0,.08)} overflow: hidden;
.p-item.active{border-color:var(--primary);background:rgba(var(--primary-rgb),.05)} }
.p-item img{width:44px;height:58px;object-fit:cover;border-radius:4px;border:1px solid var(--border);flex-shrink:0}
.p-item-info{flex:1;overflow:hidden} /* ═══ TOAST NOTIFICATION ═══ */
.p-item-code{font-weight:600;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .toast-container {
.p-item-name{font-size:11px;color:var(--m);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px} position: fixed;
.p-item-meta{display:flex;gap:6px;margin-top:4px;flex-wrap:wrap} bottom: 24px;
.p-item-meta .chip{font-size:9px;padding:2px 6px;border-radius:10px;font-weight:600} right: 24px;
.chip-line{background:rgba(var(--primary-rgb),.1);color:var(--primary)} z-index: 9999;
.chip-price{background:rgba(234,179,8,.1);color:#b45309} display: flex;
.chip-null{background:rgba(239,68,68,.1);color:#ef4444} flex-direction: column;
.chip-ok{background:rgba(34,197,94,.1);color:#16a34a} gap: 10px;
}
.pagination{display:flex;justify-content:center;align-items:center;gap:8px;padding:10px;border-top:1px solid var(--border)} .toast {
.pagination button{padding:6px 12px;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--t);cursor:pointer;font-size:12px;font-weight:600} background: var(--s);
.pagination button:hover:not(:disabled){background:var(--primary);color:#fff;border-color:var(--primary)} border: 1px solid var(--b);
.pagination button:disabled{opacity:.4;cursor:not-allowed} color: var(--t);
.pagination span{font-size:12px;color:var(--m)} padding: 12px 18px;
border-radius: 8px;
/* ═══ RIGHT PANEL ═══ */ font-size: 13px;
.right-header{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;background:var(--card)} font-weight: 500;
.right-header h3{margin:0;font-size:15px;font-weight:700;display:flex;align-items:center;gap:8px} display: flex;
.right-body{padding:20px;max-width:1400px;margin:0 auto;width:100%} align-items: center;
gap: 10px;
/* Stats Grid */ box-shadow: var(--shadow-lg);
.stats-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:24px} transform: translateY(20px);
.stat-card{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:14px;transition:all .2s} opacity: 0;
.stat-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.08)} transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
.stat-label{font-size:11px;color:var(--m);font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} }
.stat-bar{height:4px;border-radius:2px;background:rgba(var(--primary-rgb),.1);margin-bottom:6px;overflow:hidden} .toast.show {
.stat-bar-fill{height:100%;border-radius:2px;background:var(--primary);transition:width .6s ease} transform: translateY(0);
.stat-numbers{display:flex;justify-content:space-between;font-size:11px} opacity: 1;
.stat-filled{color:var(--success,#22c55e);font-weight:700} }
.stat-null{color:#ef4444;font-weight:600} .toast-success { border-left: 4px solid var(--success); }
.toast-error { border-left: 4px solid var(--danger); }
/* Detail Card */ .toast-info { border-left: 4px solid var(--primary); }
.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}
@media(max-width:1100px){.detail-grid{grid-template-columns:1fr}} /* Left panel overrides */
.card{background:var(--card);border:1px solid var(--border);border-radius:8px;overflow:hidden} .fm-left {
.card-hdr{padding:10px 14px;border-bottom:1px solid var(--border);font-weight:600;font-size:13px;display:flex;justify-content:space-between;align-items:center;background:rgba(0,0,0,.02)} background: var(--s);
.card-body{padding:14px} border-right: 1px solid var(--b);
}
/* JSON viewer */ .pagination {
.json-viewer{font-family:'Consolas','Fira Code',monospace;font-size:12px;line-height:1.6;background:#1a1a2e;color:#e4e4e7;padding:16px;border-radius:6px;overflow:auto;max-height:600px;white-space:pre-wrap;word-break:break-all} display: flex;
.json-viewer .jk{color:#7dd3fc} justify-content: space-between;
.json-viewer .jv-str{color:#86efac} align-items: center;
.json-viewer .jv-num{color:#fbbf24} padding: 12px 16px;
.json-viewer .jv-null{color:#f87171;font-style:italic} border-top: 1px solid var(--b);
.json-viewer .jv-bool{color:#c084fc} background: var(--s);
}
/* Field table */
.field-table{width:100%;border-collapse:collapse;font-size:12px} /* Custom Badges */
.field-table th{text-align:left;padding:8px;border-bottom:2px solid var(--border);font-weight:700;color:var(--m);font-size:11px;text-transform:uppercase} .chip { font-size: 9px; padding: 2px 6px; border-radius: 10px; font-weight: 600; text-transform: uppercase; }
.field-table td{padding:7px 8px;border-bottom:1px solid var(--border);vertical-align:top} .chip-line { background: var(--primary-light); color: var(--primary); }
.field-table tr:hover{background:rgba(var(--primary-rgb),.03)} .chip-null { background: var(--danger-light); color: var(--red); border: 1px solid rgba(185, 28, 28, 0.15); }
.field-name{font-weight:600;color:var(--primary);font-family:monospace;font-size:11px} .chip-ok { background: var(--green-l); color: var(--green); }
.field-value{max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.field-null{color:#ef4444;font-style:italic} .p-item-meta { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; }
/* Welcome */ /* Right panel overrides */
.welcome{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:var(--m);text-align:center;padding:40px} .fm-right {
.welcome i{margin-bottom:16px;opacity:.5} background: var(--bg);
.welcome h3{margin:0 0 8px;font-size:18px;color:var(--t)} }
.welcome p{font-size:13px;max-width:400px;line-height:1.6}
/* ═══ STATS DASHBOARD ═══ */
.stats-dashboard {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px;
}
.stat-card {
background: var(--s);
border: 1px solid var(--b);
border-radius: 10px;
padding: 16px;
cursor: pointer;
transition: all 0.2s;
}
.stat-card:hover { border-color: var(--primary); transform: translateY(-1px); box-shadow: var(--shadow-sm); }
.stat-name { font-size: 12px; font-weight: 700; font-family: monospace; color: var(--primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 8px; }
.stat-bar-container { height: 6px; border-radius: 3px; background: var(--bg); overflow: hidden; margin-bottom: 8px; }
.stat-bar-fill { height: 100%; border-radius: 3px; background: var(--primary); }
.stat-counts { display: flex; justify-content: space-between; font-size: 11px; }
/* ═══ 3-COLUMN EDITOR CONTAINER ═══ */
.panel-body.full-width {
max-width: 100% !important;
margin: 0 !important;
padding: 20px !important;
height: calc(100vh - 60px) !important;
overflow: hidden !important;
}
.editor-container {
display: grid;
grid-template-columns: 1.2fr 1.3fr 1.5fr;
gap: 20px;
height: 100%;
overflow: hidden;
margin-top: 0;
}
.editor-column {
display: flex;
flex-direction: column;
gap: 16px;
padding: 8px 16px 24px 8px;
height: 100%;
border-right: 1px solid var(--b);
overflow: hidden;
}
.editor-column.scrollable {
overflow-y: auto;
}
.editor-column:last-child { border-right: none; }
.editor-column::-webkit-scrollbar,
#simListContainer::-webkit-scrollbar,
#outfitListContainer::-webkit-scrollbar {
width: 5px;
}
.editor-column::-webkit-scrollbar-thumb,
#simListContainer::-webkit-scrollbar-thumb,
#outfitListContainer::-webkit-scrollbar-thumb {
background: var(--b);
border-radius: 4px;
}
.sec-title { font-size: 12px; font-weight: 700; display: flex; align-items: center; gap: 6px; color: var(--t); margin: 0 0 4px 0; text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid var(--b); padding-bottom: 8px; }
.sec-title span.badge { font-size: 10px; padding: 2px 6px; border-radius: 8px; background: var(--bg); color: var(--m); font-weight: 600; border: 1px solid var(--b); }
/* Product Info Section */
.prod-view-header { display: flex; gap: 14px; margin-bottom: 12px; }
.prod-view-img { width: 85px; height: 115px; object-fit: cover; border-radius: 8px; border: 1px solid var(--b); background: #fff; }
.prod-view-meta { flex: 1; display: flex; flex-direction: column; gap: 5px; }
.prod-view-title { font-size: 13.5px; font-weight: 600; line-height: 1.4; color: var(--t); }
.prod-view-code { font-family: monospace; font-size: 11px; color: var(--primary); font-weight: 600; }
.prod-view-price { font-size: 13px; font-weight: 700; color: var(--orange); }
.desc-block {
background: var(--s);
border: 1px solid var(--b);
border-radius: 8px;
padding: 12px;
font-size: 12.5px;
line-height: 1.6;
color: var(--t);
margin-bottom: 10px;
}
.desc-block-title { font-size: 11px; font-weight: 700; color: var(--m); text-transform: uppercase; margin-bottom: 6px; display: flex; align-items: center; gap: 4px; }
.badge-grid { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.tag-badge { font-size: 11px; background: var(--bg); color: var(--t); border: 1px solid var(--b); padding: 4px 10px; border-radius: 14px; display: flex; align-items: center; gap: 4px; }
/* Recommendations Manager Items */
.rec-item {
display: flex;
gap: 10px;
padding: 10px;
background: var(--s);
border: 1px solid var(--b);
border-radius: 8px;
margin-bottom: 8px;
align-items: center;
position: relative;
}
.rec-item img { width: 36px; height: 48px; object-fit: cover; border-radius: 4px; border: 1px solid var(--b); background: #fff; }
.rec-item-info { flex: 1; overflow: hidden; }
.rec-item-name { font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--t); }
.rec-item-code { font-family: monospace; font-size: 10px; color: var(--m); margin-top: 2px; }
.btn-icon { background: none; border: none; color: var(--m); cursor: pointer; padding: 4px; border-radius: 4px; display: flex; align-items: center; justify-content: center; }
.btn-icon:hover { color: var(--red); background: var(--red-l); }
.add-row { display: flex; gap: 6px; margin-bottom: 12px; }
.add-row input { flex: 1; padding: 7px 10px; font-size: 12px; border: 1px solid var(--b); background: var(--s); color: var(--t); border-radius: 6px; outline: none; }
.add-row input:focus { border-color: var(--primary); }
.add-row button { padding: 6px 12px; font-size: 12px; font-weight: 600; border: 1px solid var(--b); background: var(--s); color: var(--t); border-radius: 6px; cursor: pointer; transition: all 0.2s; }
.add-row button:hover { background: var(--primary); border-color: var(--primary); color: #fff; }
.ai-box {
border: 1px dashed rgba(59, 89, 152, 0.4);
background: rgba(59, 89, 152, 0.02);
border-radius: 8px;
padding: 12px;
margin-bottom: 14px;
}
.ai-box-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.ai-box-title { font-size: 11px; font-weight: 700; color: var(--primary); text-transform: uppercase; display: flex; align-items: center; gap: 6px; }
.ai-btn { background: var(--primary); border: none; color: #fff; padding: 4px 10px; border-radius: 6px; font-size: 10px; font-weight: 700; cursor: pointer; display: flex; align-items: center; gap: 4px; transition: background 0.15s; }
.ai-btn:hover { background: var(--primary-hover); }
.sug-grid { display: flex; flex-direction: column; gap: 6px; max-height: 200px; overflow-y: auto; padding-right: 2px; }
.sug-item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid var(--b); border-radius: 6px; background: var(--s); font-size: 11px; }
.sug-item img { width: 24px; height: 32px; object-fit: cover; border-radius: 3px; border: 1px solid var(--b); }
.sug-item-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 500; color: var(--t); }
.sug-add-btn { background: var(--bg); border: 1px solid var(--b); color: var(--primary); border-radius: 4px; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; cursor: pointer; font-weight: 700; }
.sug-add-btn:hover { background: var(--primary); color: #fff; border-color: var(--primary); }
/* Outfits Items Details */
.outfit-item-card {
border: 1px solid var(--b);
border-radius: 8px;
background: var(--s);
padding: 10px;
margin-bottom: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.outfit-item-top { display: flex; align-items: center; gap: 8px; }
.outfit-item-top img { width: 30px; height: 40px; object-fit: cover; border-radius: 4px; border: 1px solid var(--b); }
.outfit-item-meta { flex: 1; overflow: hidden; }
.outfit-item-name { font-size: 11px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--t); }
.outfit-item-sku { font-family: monospace; font-size: 10px; color: var(--m); }
.outfit-item-selects { display: flex; gap: 6px; width: 100%; }
.outfit-item-selects select { flex: 1; padding: 4px; border: 1px solid var(--b); background: var(--bg); color: var(--t); font-size: 10px; border-radius: 4px; outline: none; }
.outfit-reason-input { width: 100%; border: 1px solid var(--b); background: var(--bg); color: var(--t); font-size: 11px; border-radius: 4px; padding: 6px; outline: none; resize: vertical; min-height: 44px; line-height: 1.4; font-family: inherit; }
.outfit-reason-input:focus { border-color: var(--primary); }
/* Empty styling */
.empty-prompt {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--m);
text-align: center;
padding: 40px 10px;
}
.empty-prompt i { margin-bottom: 10px; opacity: 0.3; }
.empty-prompt h4 { margin: 0 0 4px; color: var(--t); font-size: 13px; }
.empty-prompt p { font-size: 11.5px; max-width: 250px; line-height: 1.5; margin: 0; }
/* Recycle Bin Styles */
.recycle-bin-section {
border-top: 1px dashed var(--b);
padding: 12px 0 4px;
margin-top: 12px;
}
.recycle-bin-header {
font-size: 11px;
font-weight: 700;
color: var(--m);
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
padding: 6px 4px;
background: rgba(var(--primary-rgb), 0.03);
border-radius: 4px;
transition: background 0.2s;
}
.recycle-bin-header:hover {
background: rgba(var(--primary-rgb), 0.08);
color: var(--primary);
}
.recycle-bin-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
max-height: 140px;
overflow-y: auto;
padding-right: 2px;
}
.recycle-bin-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--bg);
border: 1px solid var(--b);
border-radius: 6px;
font-size: 10.5px;
opacity: 0.85;
transition: opacity 0.2s, border-color 0.2s;
}
.recycle-bin-item:hover {
opacity: 1;
border-color: var(--primary);
}
.recycle-bin-item img {
width: 18px;
height: 24px;
object-fit: cover;
border-radius: 3px;
border: 1px solid var(--b);
}
.recycle-bin-item-info {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.recycle-bin-actions {
display: flex;
gap: 4px;
}
.btn-restore {
background: none;
border: none;
color: var(--primary);
cursor: pointer;
font-size: 9.5px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
}
.btn-restore:hover {
background: var(--primary-light);
}
.btn-delete-perm {
background: none;
border: none;
color: var(--red);
cursor: pointer;
font-size: 9.5px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
}
.btn-delete-perm:hover {
background: var(--red-l);
}
/* ═══ COLOR SWATCHES ═══ */
.color-swatches {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
padding: 4px 0;
}
.color-dot {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--b);
cursor: pointer;
transition: all 0.15s cubic-bezier(0.175, 0.885, 0.32, 1.275);
position: relative;
box-shadow: var(--shadow-sm);
}
.color-dot:hover {
transform: scale(1.25);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.4);
z-index: 2;
}
.color-dot.active {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary);
transform: scale(1.15);
}
.color-dot[title]:hover::after {
content: attr(title);
position: absolute;
bottom: 130%;
left: 50%;
transform: translateX(-50%);
background: #1a1a1a;
color: #fff;
font-size: 10px;
padding: 3px 8px;
border-radius: 4px;
white-space: nowrap;
z-index: 99;
pointer-events: none;
box-shadow: var(--shadow-md);
}
.swatches-loading {
font-size: 9px;
color: var(--m);
padding: 4px 0;
font-style: italic;
}
</style> </style>
</head> </head>
<body> <body>
<div class="app">
<div class="toast-container" id="toastContainer"></div>
<!-- ═══ LEFT PANEL ═══ -->
<div class="left-panel"> <div class="fm-layout">
<div class="left-header">
<div class="left-title"> <!-- ═══ LEFT PANEL (SIDEBAR) ═══ -->
<i data-lucide="database" style="width:18px;height:18px;color:var(--primary)"></i> <div class="fm-left">
Product Catalog <div class="fm-left-header">
<span class="badge badge-warning" style="font-size:10px;margin-left:auto" id="totalBadge">0</span> <div class="fm-left-header-title">
<div onclick="showOverview()" style="cursor:pointer; display:flex; align-items:center; gap:8px;">
<i data-lucide="database" class="icon-sm" style="color:var(--primary)"></i>
<span>Catalog sản phẩm</span>
</div>
<span class="chip chip-line" id="totalBadge">0</span>
</div> </div>
<div class="filter-row"> <div style="display:flex; gap:6px;">
<select id="filterType" onchange="applyFilter()"> <select id="filterType" onchange="applyFilter()" class="select" style="width: 130px; padding: 6px;">
<option value="">All Products</option> <option value="">Tất cả</option>
<option value="null">Has NULL in...</option> <option value="null_similar">Thiếu Similar</option>
<option value="filled">Has value in...</option> <option value="null_outfits">Thiếu Outfits</option>
</select>
<select id="filterCol" onchange="applyFilter()" style="display:none">
<option value="">— Column —</option>
</select> </select>
<input type="text" id="searchInput" placeholder="Search name or code..." onkeydown="if(event.key==='Enter')applyFilter()"> <div class="fm-search-bar" style="flex:1;">
<button onclick="applyFilter()" style="padding:6px 10px;border:1px solid var(--primary);border-radius:6px;background:var(--primary);color:#fff;cursor:pointer;font-size:11px;font-weight:600">Go</button> <i data-lucide="search" class="icon-sm"></i>
<input type="text" id="searchInput" placeholder="SKU hoặc tên..." onkeydown="if(event.key==='Enter')applyFilter()">
</div>
</div> </div>
</div> </div>
<div class="product-list" id="productList">
<div style="text-align:center;color:var(--m);padding:30px;font-size:13px">Loading...</div> <div class="fm-list" id="productList">
<div style="text-align:center;color:var(--m);padding:40px;font-size:12px">Đang tải sản phẩm...</div>
</div> </div>
<div class="pagination"> <div class="pagination">
<button id="btnPrev" onclick="changePage(-1)" disabled>← Prev</button> <button id="btnPrev" onclick="changePage(-1)" class="btn btn-outline btn-sm" disabled>Trước</button>
<span id="pageInfo">1/1</span> <span id="pageInfo" style="font-size:12px; color:var(--m)">1 / 1</span>
<button id="btnNext" onclick="changePage(1)" disabled>Next →</button> <button id="btnNext" onclick="changePage(1)" class="btn btn-outline btn-sm" disabled>Sau</button>
</div> </div>
</div> </div>
<!-- ═══ RIGHT PANEL ═══ --> <!-- ═══ RIGHT PANEL (CONTENT) ═══ -->
<div class="right-panel" id="rightPanel"> <div class="fm-right">
<div class="right-header"> <div class="panel-header">
<h3 id="rightTitle"> <div>
<i data-lucide="bar-chart-3" style="width:18px;height:18px;color:var(--primary)"></i> <h1 id="rightTitle" style="margin:0;font-size:15px;font-weight:700;color:var(--t);display:flex;align-items:center;gap:8px">
Overview Statistics <i data-lucide="bar-chart-3" style="width:20px;height:20px;color:var(--primary)"></i> Thống kê tổng quan
</h3> </h1>
<p id="rightSubtitle" style="margin:0;font-size:11px;color:var(--m)">Độ hoàn thiện đề xuất Catalog sản phẩm</p>
</div>
<div id="headerActions">
<!-- Save button when active -->
</div>
</div> </div>
<div class="right-body" id="rightBody">
<!-- Stats loaded here on init --> <div class="panel-body" id="rightBody">
<div style="text-align:center;color:var(--m);padding:40px;font-size:13px">Loading stats...</div> <!-- Loaded dynamically -->
</div> </div>
</div> </div>
</div> </div>
<script> <script>
// ═══ STATE ═══ // ═══ STATE ═══
let currentPage = 1; let currentPage = 1;
let totalPages = 1; let totalPages = 1;
let totalProducts = 0; let totalProducts = 0;
const LIMIT = 50; const LIMIT = 50;
let allColumns = [];
let importantColumns = []; const NO_IMAGE_SVG = `data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="130" viewBox="0 0 100 130"><rect width="100" height="130" rx="6" fill="%23E2E0D8"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="10" font-weight="600" fill="%238C8A82">NO IMAGE</text></svg>`;
let currentCode = null;
let currentProduct = null;
// ═══ INIT ═══ let currentSimilarItems = [];
async function init() { let currentOutfits = [];
lucide.createIcons();
// Load columns for filter dropdown // ═══ IMAGE URL FORMATTER ═══
try { function formatImageUrl(url) {
const res = await fetch('/api/manage-ultra-desc/columns'); if (!url) return '';
const data = await res.json(); // Normalize any cdneverest.net URL to media.canifa.com to avoid DNS resolution blocks
allColumns = data.columns || []; let clean = url.replace(/https?:\/\/2885371169\.e\.cdneverest\.net\/pub\/media\//g, 'https://media.canifa.com/');
importantColumns = data.important || []; clean = clean.replace(/https?:\/\/2885371169\.e\.cdneverest\.net\//g, 'https://media.canifa.com/');
const sel = document.getElementById('filterCol'); return clean;
importantColumns.forEach(c => { }
const opt = document.createElement('option');
opt.value = c; // ═══ COLOR SWATCHES ═══
opt.textContent = c; const COLOR_MAP = {
sel.appendChild(opt); // English
}); 'black': '#1a1a1a', 'white': '#fafafa', 'red': '#e53e3e', 'blue': '#3182ce',
} catch(e) { console.error(e); } 'pink': '#ed64a6', 'brown': '#8B4513', 'yellow': '#ecc94b', 'green': '#38a169',
'gray': '#a0aec0', 'grey': '#a0aec0', 'beige': '#d4c5a9', 'aqua': '#00b5d8',
loadStats(); 'purple': '#805ad5', 'orange': '#dd6b20', 'navy': '#1A365D', 'cream': '#FDF6E2',
loadProducts(); // Vietnamese
} 'đen': '#1a1a1a', 'trắng': '#fafafa', 'đỏ': '#e53e3e', 'xanh dương': '#3182ce',
'xanh biển': '#3182ce', 'xanh lam': '#3182ce', 'hồng': '#ed64a6', 'nâu': '#8B4513',
// ═══ STATS ═══ 'vàng': '#ecc94b', 'ghi': '#a0aec0', 'xám': '#a0aec0', 'xanh lá': '#38a169',
async function loadStats() { 'xanh lục': '#38a169', 'cam': '#dd6b20', 'tím': '#805ad5', 'xanh ngọc': '#00b5d8',
try { 'kem': '#FDF6E2',
const res = await fetch('/api/manage-ultra-desc/stats'); };
const data = await res.json();
if (data.status !== 'success') return; const CANIFA_PREFIX_MAP = {
'SK': '#1a1a1a', // Black
const total = data.total; 'SW': '#fafafa', // White
const cols = data.columns; 'SB': '#3182ce', // Blue
'SM': '#ed64a6', // Pink
let html = `<div class="stats-grid"> 'SN': '#8B4513', // Brown / Beige
<div class="stat-card" style="grid-column:span 2;background:linear-gradient(135deg,rgba(var(--primary-rgb),.08),rgba(var(--primary-rgb),.02))"> 'SY': '#ecc94b', // Yellow
<div class="stat-label">Total Products in StarRocks</div> 'SA': '#a0aec0', // Gray
<div style="font-size:28px;font-weight:800;color:var(--primary);margin:4px 0">${total.toLocaleString()}</div> 'SG': '#38a169', // Green
<div style="font-size:11px;color:var(--m)">test_db.magento_product_dimension_with_text_embedding</div> 'SR': '#e53e3e', // Red
</div>`; 'SO': '#dd6b20', // Orange
'SV': '#805ad5', // Purple
// Sort by fill_pct ascending (worst first) 'SD': '#c53030', // Dark Red
const sorted = Object.entries(cols).sort((a,b) => a[1].fill_pct - b[1].fill_pct); 'SP': '#fbb6ce', // Light Pink
for (const [col, info] of sorted) { 'SF': '#ecc94b', // Yellow/Orange
const pct = info.fill_pct; };
const barColor = pct > 90 ? 'var(--success,#22c55e)' : pct > 50 ? '#eab308' : '#ef4444';
html += ` const variantsCache = {}; // cache by SKU
<div class="stat-card" style="cursor:pointer" onclick="filterByNull('${col}')">
<div class="stat-label" title="${col}">${col}</div> function colorNameToHex(colorName, sku = '') {
<div class="stat-bar"><div class="stat-bar-fill" style="width:${pct}%;background:${barColor}"></div></div> if (colorName) {
<div class="stat-numbers"> const lower = colorName.toLowerCase();
<span class="stat-filled">${info.filled_count.toLocaleString()} filled</span> for (const [key, hex] of Object.entries(COLOR_MAP)) {
<span class="stat-null">${info.null_count.toLocaleString()} null</span> if (lower.includes(key)) return hex;
</div> }
</div>`;
} }
html += '</div>'; if (sku) {
document.getElementById('rightBody').innerHTML = html; const parts = sku.split('-');
document.getElementById('rightTitle').innerHTML = '<i data-lucide="bar-chart-3" style="width:18px;height:18px;color:var(--primary)"></i> Overview Statistics'; if (parts.length > 1) {
lucide.createIcons(); const colorPart = parts[parts.length - 1];
} catch(e) { const prefix = colorPart.substring(0, 2).toUpperCase();
console.error(e); if (CANIFA_PREFIX_MAP[prefix]) {
document.getElementById('rightBody').innerHTML = '<div style="color:#ef4444;padding:20px">Error loading stats</div>'; return CANIFA_PREFIX_MAP[prefix];
} }
} }
function filterByNull(col) {
document.getElementById('filterType').value = 'null';
document.getElementById('filterCol').style.display = '';
document.getElementById('filterCol').value = col;
currentPage = 1;
loadProducts();
}
// ═══ FILTER ═══
function applyFilter() {
const type = document.getElementById('filterType').value;
const colSel = document.getElementById('filterCol');
colSel.style.display = type ? '' : 'none';
currentPage = 1;
loadProducts();
}
function changePage(delta) {
currentPage += delta;
if (currentPage < 1) currentPage = 1;
if (currentPage > totalPages) currentPage = totalPages;
loadProducts();
}
// ═══ PRODUCT LIST ═══
async function loadProducts() {
const type = document.getElementById('filterType').value;
const col = document.getElementById('filterCol').value;
const search = document.getElementById('searchInput').value.trim();
let url = `/api/manage-ultra-desc/list?page=${currentPage}&limit=${LIMIT}`;
if (type === 'null' && col) url += `&null_field=${col}`;
if (type === 'filled' && col) url += `&filled_field=${col}`;
if (search) url += `&search=${encodeURIComponent(search)}`;
document.getElementById('productList').innerHTML = '<div style="text-align:center;color:var(--m);padding:30px;font-size:13px">Loading...</div>';
try {
const res = await fetch(url);
const data = await res.json();
if (data.status !== 'success') {
document.getElementById('productList').innerHTML = `<div style="color:#ef4444;padding:20px">${data.message}</div>`;
return;
} }
return '#ccc';
}
totalProducts = data.total; async function fetchColorVariants(sku) {
totalPages = Math.max(1, Math.ceil(totalProducts / LIMIT)); if (!sku) return [];
document.getElementById('totalBadge').textContent = totalProducts.toLocaleString(); if (variantsCache[sku]) return variantsCache[sku];
document.getElementById('pageInfo').textContent = `${currentPage}/${totalPages}`; try {
document.getElementById('btnPrev').disabled = currentPage <= 1; const res = await fetch(`/api/manage-ultra-desc/color-variants?sku=${encodeURIComponent(sku)}`);
document.getElementById('btnNext').disabled = currentPage >= totalPages; const data = await res.json();
if (data.status === 'success' && data.variants && data.variants.length > 1) {
variantsCache[sku] = data.variants;
return data.variants;
}
} catch (e) {
console.error('fetchColorVariants error:', e);
}
return [];
}
const list = document.getElementById('productList'); function renderSwatchesForItem(containerEl, sku, variants, onSelectCallback) {
if (!data.products.length) { if (!variants || variants.length <= 1) return;
list.innerHTML = '<div style="text-align:center;color:var(--m);padding:30px;font-size:13px">No products found.</div>'; const wrapper = document.createElement('div');
return; wrapper.className = 'color-swatches';
variants.forEach(v => {
const dot = document.createElement('span');
const isCurrentActive = (v.product_color_code === sku || v.magento_ref_code === sku);
dot.className = 'color-dot' + (isCurrentActive ? ' active' : '');
const hex = colorNameToHex(v.product_color_name, v.product_color_code || v.magento_ref_code);
dot.style.backgroundColor = hex;
if (hex === '#fafafa' || hex === '#FDF6E2') {
dot.style.border = '2px solid #ccc';
}
dot.title = `${v.product_color_name || '?'} (${v.product_color_code || v.magento_ref_code})`;
dot.addEventListener('click', () => onSelectCallback(v));
wrapper.appendChild(dot);
});
containerEl.appendChild(wrapper);
}
// ═══ RECYCLE BIN OPERATIONS (LOCAL STORAGE) ═══
const RECYCLE_BIN_KEY = 'canifa_recycle_bin';
let simBinExpanded = false;
let outBinExpanded = false;
function getRecycleBin() {
try {
const data = localStorage.getItem(RECYCLE_BIN_KEY);
return data ? JSON.parse(data) : [];
} catch (e) {
console.error(e);
return [];
} }
}
list.innerHTML = ''; function saveRecycleBin(bin) {
data.products.forEach(p => { try {
const code = p.magento_ref_code || p.internal_ref_code || '—'; localStorage.setItem(RECYCLE_BIN_KEY, JSON.stringify(bin));
const hasDesc = p.description_text_full_save && p.description_text_full_save !== ''; } catch (e) {
const hasTags = p.tags && p.tags !== '' && p.tags !== '[]'; console.error(e);
const price = p.sale_price ? `${Number(p.sale_price).toLocaleString()}đ` : ''; }
}
const div = document.createElement('div'); function addToRecycleBin(type, item) {
div.className = 'p-item' + (currentCode === code ? ' active' : ''); if (!currentProduct) return;
div.onclick = () => loadDetail(code, div); const parentCode = currentProduct.magento_ref_code || currentProduct.internal_ref_code;
div.innerHTML = ` const bin = getRecycleBin();
<img src="${p.product_image_url_thumbnail || ''}" alt="" onerror="this.src='/static/common/img/placeholder.png'"> bin.push({
<div class="p-item-info"> id: Date.now() + Math.random().toString(36).substr(2, 5),
<div class="p-item-code">${code}</div> parent_code: parentCode,
<div class="p-item-name">${p.product_name || '—'}</div> type: type, // 'similar' | 'outfit'
<div class="p-item-meta"> item: item,
${p.product_line_vn ? `<span class="chip chip-line">${p.product_line_vn}</span>` : ''} deleted_at: Date.now()
${price ? `<span class="chip chip-price">${price}</span>` : ''}
${hasDesc ? '<span class="chip chip-ok">desc ✓</span>' : '<span class="chip chip-null">no desc</span>'}
${hasTags ? '<span class="chip chip-ok">tags ✓</span>' : '<span class="chip chip-null">no tags</span>'}
</div>
</div>`;
list.appendChild(div);
}); });
} catch(e) { // Limit to last 50 deleted items to prevent local storage bloat
console.error(e); if (bin.length > 50) bin.shift();
document.getElementById('productList').innerHTML = '<div style="color:#ef4444;padding:20px">Network error</div>'; saveRecycleBin(bin);
} renderRecycleBin();
} }
// ═══ DETAIL ═══ function restoreFromRecycleBin(id) {
async function loadDetail(code, el) { let bin = getRecycleBin();
currentCode = code; const index = bin.findIndex(x => x.id === id);
document.querySelectorAll('.p-item').forEach(e => e.classList.remove('active')); if (index === -1) return;
if (el) el.classList.add('active'); const record = bin[index];
document.getElementById('rightTitle').innerHTML = `<i data-lucide="box" style="width:18px;height:18px;color:var(--primary)"></i> ${code}`; // Add back to active list if the parent product is still the current product
document.getElementById('rightBody').innerHTML = '<div style="text-align:center;color:var(--m);padding:40px;font-size:13px">Loading detail...</div>'; const currentCode = currentProduct.magento_ref_code || currentProduct.internal_ref_code;
lucide.createIcons(); if (record.parent_code === currentCode) {
if (record.type === 'similar') {
try { if (!currentSimilarItems.some(i => i.magento_ref_code === record.item.magento_ref_code || i.sku === record.item.sku)) {
const res = await fetch(`/api/manage-ultra-desc/detail/${code}`); currentSimilarItems.push(record.item);
const data = await res.json(); renderSimilarItemsList();
if (data.status !== 'success') { }
document.getElementById('rightBody').innerHTML = `<div style="color:#ef4444;padding:20px">${data.message}</div>`; } else {
return; if (!currentOutfits.some(i => i.sku === record.item.sku)) {
currentOutfits.push(record.item);
renderOutfitsList();
}
}
showToast(`Đã khôi phục sản phẩm ${record.item.magento_ref_code || record.item.sku || record.item.sku_color}`, "success");
} else {
showToast("Sản phẩm đã xóa thuộc về SKU khác! Không thể khôi phục trực tiếp.", "error");
} }
const p = data.product; // Remove from recycle bin
const imgUrl = p.product_image_url || p.product_image_url_thumbnail || ''; bin.splice(index, 1);
saveRecycleBin(bin);
renderRecycleBin();
}
function deletePermanentlyFromRecycleBin(id) {
let bin = getRecycleBin();
bin = bin.filter(x => x.id !== id);
saveRecycleBin(bin);
renderRecycleBin();
showToast("Đã xóa vĩnh viễn khỏi Thùng rác", "info");
}
// Build field table (short fields) function renderRecycleBin() {
const shortFields = {}; if (!currentProduct) return;
const longFields = {}; const currentCode = currentProduct.magento_ref_code || currentProduct.internal_ref_code;
for (const [k, v] of Object.entries(p)) { const bin = getRecycleBin();
if (k === 'vector') continue;
const s = v === null ? null : String(v); // Filter items belonging to the current product
if (s && s.length > 200) { const simBinItems = bin.filter(x => x.parent_code === currentCode && x.type === 'similar');
longFields[k] = v; const outBinItems = bin.filter(x => x.parent_code === currentCode && x.type === 'outfit');
// Render Similar Recycle Bin
const simContainer = document.getElementById('simRecycleBin');
if (simContainer) {
if (simBinItems.length === 0) {
simContainer.innerHTML = '';
simContainer.style.display = 'none';
} else { } else {
shortFields[k] = v; simContainer.style.display = 'block';
let listHtml = '';
if (simBinExpanded) {
listHtml = `
<div class="recycle-bin-list">
${simBinItems.map(x => `
<div class="recycle-bin-item">
<img src="${formatImageUrl(x.item.product_image_url_thumbnail)}" onerror="this.onerror=null; this.src=NO_IMAGE_SVG;">
<div class="recycle-bin-item-info" title="${x.item.product_name || ''}">
<b>${x.item.magento_ref_code || x.item.sku}</b> - ${x.item.product_name || ''}
</div>
<div class="recycle-bin-actions">
<button class="btn-restore" onclick="restoreFromRecycleBin('${x.id}')" title="Khôi phục">Khôi phục</button>
<button class="btn-delete-perm" onclick="deletePermanentlyFromRecycleBin('${x.id}')" title="Xóa hẳn">Xóa</button>
</div>
</div>
`).join('')}
</div>
`;
}
simContainer.innerHTML = `
<div class="recycle-bin-header" onclick="toggleSimBin()">
<span style="display:flex;align-items:center;gap:4px"><i data-lucide="trash-2" style="width:12px;height:12px;color:var(--red)"></i> Thùng rác tạm thời (${simBinItems.length})</span>
<i data-lucide="${simBinExpanded ? 'chevron-down' : 'chevron-right'}" style="width:12px;height:12px;"></i>
</div>
${listHtml}
`;
}
}
// Render Outfit Recycle Bin
const outContainer = document.getElementById('outfitRecycleBin');
if (outContainer) {
if (outBinItems.length === 0) {
outContainer.innerHTML = '';
outContainer.style.display = 'none';
} else {
outContainer.style.display = 'block';
let listHtml = '';
if (outBinExpanded) {
listHtml = `
<div class="recycle-bin-list">
${outBinItems.map(x => `
<div class="recycle-bin-item">
<img src="${formatImageUrl(x.item.product_image_url_thumbnail)}" onerror="this.onerror=null; this.src=NO_IMAGE_SVG;">
<div class="recycle-bin-item-info" title="${x.item.match_product_name || ''}">
<b>${x.item.sku}</b> - ${x.item.match_product_name || ''}
</div>
<div class="recycle-bin-actions">
<button class="btn-restore" onclick="restoreFromRecycleBin('${x.id}')" title="Khôi phục">Khôi phục</button>
<button class="btn-delete-perm" onclick="deletePermanentlyFromRecycleBin('${x.id}')" title="Xóa hẳn">Xóa</button>
</div>
</div>
`).join('')}
</div>
`;
}
outContainer.innerHTML = `
<div class="recycle-bin-header" onclick="toggleOutBin()">
<span style="display:flex;align-items:center;gap:4px"><i data-lucide="trash-2" style="width:12px;height:12px;color:var(--red)"></i> Thùng rác tạm thời (${outBinItems.length})</span>
<i data-lucide="${outBinExpanded ? 'chevron-down' : 'chevron-right'}" style="width:12px;height:12px;"></i>
</div>
${listHtml}
`;
} }
} }
lucide.createIcons();
}
function toggleSimBin() {
simBinExpanded = !simBinExpanded;
renderRecycleBin();
}
function toggleOutBin() {
outBinExpanded = !outBinExpanded;
renderRecycleBin();
}
// ═══ INIT ═══
async function init() {
lucide.createIcons();
showOverview();
loadProducts();
}
// ═══ TOAST HELPERS ═══
function showToast(msg, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
let icon = 'info';
if (type === 'success') icon = 'check-circle';
if (type === 'error') icon = 'alert-triangle';
toast.innerHTML = `
<i data-lucide="${icon}" style="width:16px;height:16px"></i>
<span>${msg}</span>
`;
container.appendChild(toast);
lucide.createIcons();
setTimeout(() => toast.classList.add('show'), 10);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// ═══ STATS & OVERVIEW ═══
async function showOverview() {
currentProduct = null;
document.querySelectorAll('.fm-item').forEach(e => e.classList.remove('active'));
// Reset right header content
document.getElementById('rightTitle').innerHTML = `
<i data-lucide="bar-chart-3" style="width:20px;height:20px;color:var(--primary)"></i> Thống kê tổng quan
`;
document.getElementById('rightSubtitle').textContent = "Độ hoàn thiện đề xuất Catalog sản phẩm";
document.getElementById('headerActions').innerHTML = '';
const panelBody = document.getElementById('rightBody');
panelBody.className = "panel-body"; // reset container sizes
panelBody.innerHTML = `
<div style="text-align:center;color:var(--m);padding:40px;font-size:12px">Đang tải biểu đồ thống kê...</div>
`;
lucide.createIcons();
try {
const res = await fetch('/api/manage-ultra-desc/stats');
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
const total = data.total;
const cols = data.columns;
const nullSimilar = cols.similar_items ? cols.similar_items.null_count : 0;
const nullOutfits = cols.outfit_recommendations ? cols.outfit_recommendations.null_count : 0;
let colsHtml = '';
const importantList = [
'description_data_for_production', 'tags', 'similar_items', 'outfit_recommendations',
'product_name', 'product_image_url', 'description_text'
];
importantList.forEach(col => {
if (!cols[col]) return;
const info = cols[col];
const pct = info.fill_pct;
const barColor = pct > 90 ? 'var(--success)' : pct > 50 ? 'var(--warning)' : 'var(--danger)';
colsHtml += `
<div class="stat-card" onclick="filterByColumnNull('${col}')">
<div class="stat-name">${col}</div>
<div class="stat-bar-container">
<div class="stat-bar-fill" style="width: ${pct}%; background: ${barColor}"></div>
</div>
<div class="stat-counts">
<span style="color:var(--success)">${info.filled_count.toLocaleString()} OK</span>
<span style="color:var(--danger)">${info.null_count.toLocaleString()} Trống</span>
</div>
</div>
`;
});
panelBody.innerHTML = `
<div class="stats-dashboard">
<div class="kpi-row" style="grid-template-columns: repeat(3, 1fr);">
<div class="kpi-card">
<div class="num" style="color: var(--primary)">${total.toLocaleString()}</div>
<div class="lbl">Tổng sản phẩm</div>
</div>
<div class="kpi-card" style="cursor:pointer" onclick="filterByColumnNull('similar_items')">
<div class="num" style="color: var(--red)">${nullSimilar.toLocaleString()}</div>
<div class="lbl">Thiếu Similar Items</div>
</div>
<div class="kpi-card" style="cursor:pointer" onclick="filterByColumnNull('outfit_recommendations')">
<div class="num" style="color: var(--gold)">${nullOutfits.toLocaleString()}</div>
<div class="lbl">Thiếu phối đồ (Outfits)</div>
</div>
</div>
<div>
<div class="sec-header">Kiểm toán chất lượng dữ liệu</div>
<div class="stats-grid">
${colsHtml}
</div>
</div>
</div>
`;
lucide.createIcons();
} catch (e) {
console.error(e);
panelBody.innerHTML = `
<div class="empty-state">
<div class="icon">⚠️</div>
<div class="title">Không thể tải thống kê</div>
<div class="desc">${e.message}</div>
</div>
`;
}
}
function filterByColumnNull(col) {
if (col === 'similar_items') {
document.getElementById('filterType').value = 'null_similar';
} else if (col === 'outfit_recommendations') {
document.getElementById('filterType').value = 'null_outfits';
} else {
document.getElementById('filterType').value = '';
}
currentPage = 1;
loadProducts();
}
// ═══ PRODUCTS LISTING ═══
async function loadProducts() {
const type = document.getElementById('filterType').value;
const search = document.getElementById('searchInput').value.trim();
let url = `/api/manage-ultra-desc/list?page=${currentPage}&limit=${LIMIT}`;
if (type === 'null_similar') url += '&null_field=similar_items';
if (type === 'null_outfits') url += '&null_field=outfit_recommendations';
if (search) url += `&search=${encodeURIComponent(search)}`;
document.getElementById('productList').innerHTML = `
<div style="text-align:center;color:var(--m);padding:40px;font-size:12px">Đang quét danh sách...</div>
`;
try {
const res = await fetch(url);
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
totalProducts = data.total;
totalPages = Math.max(1, Math.ceil(totalProducts / LIMIT));
document.getElementById('totalBadge').textContent = totalProducts.toLocaleString();
document.getElementById('pageInfo').textContent = `${currentPage} / ${totalPages}`;
document.getElementById('btnPrev').disabled = currentPage <= 1;
document.getElementById('btnNext').disabled = currentPage >= totalPages;
const list = document.getElementById('productList');
if (!data.products.length) {
list.innerHTML = `
<div style="text-align:center;color:var(--m);padding:40px;font-size:12px">Không tìm thấy sản phẩm nào.</div>
`;
return;
}
list.innerHTML = '';
data.products.forEach(p => {
const code = p.magento_ref_code || p.internal_ref_code;
// Similar Items Status
const hasSim = p.has_sim;
const simBadge = hasSim
? '<span class="chip chip-ok">sim ✓</span>'
: '<span class="chip chip-null">no sim</span>';
// Outfits Status
const hasOut = p.has_out;
const outBadge = hasOut
? '<span class="chip chip-ok">out ✓</span>'
: '<span class="chip chip-null">no outfit</span>';
const lineBadge = p.product_line_vn
? `<span class="chip chip-line">${p.product_line_vn}</span>`
: '';
const div = document.createElement('div');
div.className = 'fm-item has-image';
div.id = `p-item-${code}`;
div.onclick = () => selectProduct(code);
div.innerHTML = `
<img src="${formatImageUrl(p.product_image_url_thumbnail)}" onerror="this.onerror=null; this.src=NO_IMAGE_SVG;">
<div class="fm-item-info">
<div class="fm-title-sm" style="display:flex; justify-content:space-between; align-items:center;">
<span style="font-family:monospace; font-weight:700">${code}</span>
<span style="font-size:10px; font-weight:400; color:var(--m)">${p.quantity_sold ? Math.round(p.quantity_sold) : 0} đã bán</span>
</div>
<div class="fm-desc-sm" style="margin-top:2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="${p.product_name || ''}">${p.product_name || '—'}</div>
<div class="p-item-meta">
${lineBadge}
${simBadge}
${outBadge}
</div>
</div>
`;
list.appendChild(div);
});
let tableHtml = ''; if (currentProduct) {
for (const [k, v] of Object.entries(shortFields)) { const activeEl = document.getElementById(`p-item-${currentProduct.magento_ref_code || currentProduct.internal_ref_code}`);
const isNull = v === null || v === '' || v === undefined; if (activeEl) activeEl.classList.add('active');
tableHtml += `<tr>
<td class="field-name">${k}</td>
<td class="${isNull ? 'field-null' : 'field-value'}">${isNull ? 'NULL' : escapeHtml(String(v))}</td>
</tr>`;
}
// Build JSON panels for long fields
let longHtml = '';
for (const [k, v] of Object.entries(longFields)) {
let formatted;
try {
const parsed = JSON.parse(v);
formatted = syntaxHighlight(JSON.stringify(parsed, null, 2));
} catch {
formatted = escapeHtml(String(v));
} }
longHtml += ` } catch (e) {
<div class="card" style="margin-top:16px"> console.error(e);
<div class="card-hdr"><span><i data-lucide="file-json" style="width:14px;height:14px"></i> ${k}</span> document.getElementById('productList').innerHTML = `
<span style="font-size:11px;color:var(--m)">${String(v).length.toLocaleString()} chars</span></div> <div style="color:var(--danger);padding:20px;text-align:center;font-size:12px">Lỗi tải dữ liệu: ${e.message}</div>
<div class="card-body" style="padding:0"><div class="json-viewer">${formatted}</div></div> `;
</div>`; }
}
function applyFilter() {
currentPage = 1;
loadProducts();
}
function changePage(delta) {
currentPage += delta;
if (currentPage < 1) currentPage = 1;
if (currentPage > totalPages) currentPage = totalPages;
loadProducts();
}
// ═══ SIDEBAR ITEM LOCAL UPDATE ═══
function updateSidebarItem(code, hasSim, hasOutfit) {
const itemEl = document.getElementById(`p-item-${code}`);
if (!itemEl) return;
const metaEl = itemEl.querySelector('.p-item-meta');
if (!metaEl) return;
const lineChip = metaEl.querySelector('.chip-line');
const lineBadge = lineChip ? lineChip.outerHTML : '';
const simBadge = hasSim
? '<span class="chip chip-ok">sim ✓</span>'
: '<span class="chip chip-null">no sim</span>';
const outBadge = hasOutfit
? '<span class="chip chip-ok">outfit ✓</span>'
: '<span class="chip chip-null">no outfit</span>';
metaEl.innerHTML = `${lineBadge} ${simBadge} ${outBadge}`;
}
// ═══ PRODUCT DETAIL & EDITOR ═══
async function selectProduct(code) {
document.querySelectorAll('.fm-item').forEach(e => e.classList.remove('active'));
const itemEl = document.getElementById(`p-item-${code}`);
if (itemEl) itemEl.classList.add('active');
document.getElementById('rightTitle').innerHTML = `
<i data-lucide="loader" class="spinner" style="width:18px;height:18px;color:var(--primary)"></i>
Đang tải ${code}...
`;
document.getElementById('rightSubtitle').textContent = "Tìm gợi ý và cập nhật thông tin";
document.getElementById('headerActions').innerHTML = '';
const panelBody = document.getElementById('rightBody');
panelBody.className = "panel-body full-width"; // expand panel size to full screen grid
panelBody.innerHTML = `
<div style="text-align:center;color:var(--m);padding:60px;font-size:13px">Đang tải thông tin chi tiết và bộ đề xuất...</div>
`;
lucide.createIcons();
try {
const res = await fetch(`/api/manage-ultra-desc/detail/${code}`);
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
currentProduct = data.product;
currentSimilarItems = currentProduct.parsed_similar_items || [];
currentOutfits = currentProduct.parsed_outfit_recommendations || [];
// Update sidebar state badges to match current database state
const hasSim = currentSimilarItems.length > 0;
const hasOutfit = currentOutfits.length > 0;
updateSidebarItem(code, hasSim, hasOutfit);
// Render Title Header with Action Buttons
document.getElementById('rightTitle').innerHTML = `
<i data-lucide="box" style="width:20px;height:20px;color:var(--primary)"></i>
<span>${currentProduct.product_name || code}</span>
`;
const webUrl = currentProduct.product_web_url || `https://canifa.com/${code}`;
document.getElementById('rightSubtitle').innerHTML = `
Mã sản phẩm:
<a href="${webUrl}" target="_blank" title="Xem trên Canifa" style="font-family:monospace;font-weight:700;color:var(--primary);text-decoration:underline;display:inline-flex;align-items:center;gap:4px">
${code}
<i data-lucide="external-link" style="width:12px;height:12px;display:inline-block;vertical-align:middle"></i>
</a>
`;
document.getElementById('headerActions').innerHTML = `
<button class="btn btn-primary btn-sm" id="btnSave" onclick="saveRecommendations()">
<i data-lucide="save" style="width:14px;height:14px"></i>
<span>Lưu thay đổi</span>
</button>
`;
renderEditor();
} catch (e) {
console.error(e);
panelBody.innerHTML = `
<div class="empty-state">
<div class="icon">⚠️</div>
<div class="title">Không thể tải thông tin chi tiết</div>
<div class="desc">${e.message}</div>
</div>
`;
}
}
async function selectColorVariant(variantSku) {
const rightTitleEl = document.getElementById('rightTitle');
const originalTitle = rightTitleEl.innerHTML;
rightTitleEl.innerHTML = `
<i data-lucide="loader" class="spinner" style="width:16px;height:16px;color:var(--primary);margin-right:8px"></i>
<span>Đang chuyển màu...</span>
`;
lucide.createIcons();
try {
const res = await fetch(`/api/manage-ultra-desc/detail/${variantSku}`);
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
currentProduct = data.product;
currentSimilarItems = currentProduct.parsed_similar_items || [];
currentOutfits = currentProduct.parsed_outfit_recommendations || [];
// Cập nhật trạng thái sidebar dựa trên dữ liệu mới của biến thể màu
const baseCode = currentProduct.magento_ref_code || currentProduct.internal_ref_code;
const hasSim = currentSimilarItems.length > 0;
const hasOutfit = currentOutfits.length > 0;
updateSidebarItem(baseCode, hasSim, hasOutfit);
// Cập nhật lại subtitle và header
const webUrl = currentProduct.product_web_url || `https://canifa.com/${variantSku}`;
document.getElementById('rightSubtitle').innerHTML = `
Mã sản phẩm:
<a href="${webUrl}" target="_blank" title="Xem trên Canifa" style="font-family:monospace;font-weight:700;color:var(--primary);text-decoration:underline;display:inline-flex;align-items:center;gap:4px">
${variantSku}
<i data-lucide="external-link" style="width:12px;height:12px;display:inline-block;vertical-align:middle"></i>
</a>
`;
// Cập nhật lại title chính
rightTitleEl.innerHTML = `
<i data-lucide="box" style="width:20px;height:20px;color:var(--primary)"></i>
<span>${currentProduct.product_name || baseCode}</span>
`;
renderEditor();
} catch (e) {
console.error(e);
showToast(`Không thể tải biến thể màu: ${e.message}`, "error");
rightTitleEl.innerHTML = originalTitle;
lucide.createIcons();
}
}
function renderEditor() {
const p = currentProduct;
const descData = p.parsed_description_data || {};
const imgUrl = formatImageUrl(p.product_image_url || p.product_image_url_thumbnail || '');
// Tags list mapping
let tagsHtml = '';
const tagsArr = p.tags ? p.tags.split(',').map(t => t.trim()).filter(t => t) : [];
if (tagsArr.length) {
tagsHtml = tagsArr.map(t => `<span class="tag-badge"><i data-lucide="tag" style="width:10px;height:10px;color:var(--primary)"></i>${t}</span>`).join('');
} else {
tagsHtml = '<span style="font-size:11px;color:var(--m);font-style:italic">Chưa gán tag</span>';
} }
// Specifications listing
const specFields = [
{ label: "Nhóm hàng", val: p.product_group },
{ label: "Giới tính", val: p.gender_by_product },
{ label: "Độ tuổi", val: p.age_by_product },
{ label: "Mùa vụ", val: p.season },
{ label: "Phong cách", val: p.style }
];
let specHtml = specFields.map(f => `
<div style="display:flex;justify-content:space-between;font-size:12px;border-bottom:1px solid var(--b);padding:6px 0">
<span style="color:var(--m);font-weight:500">${f.label}</span>
<span style="font-weight:600;color:var(--t)">${f.val || '—'}</span>
</div>
`).join('');
document.getElementById('rightBody').innerHTML = ` document.getElementById('rightBody').innerHTML = `
<div class="detail-grid"> <div class="editor-container">
<div>
${imgUrl ? `<div class="card" style="margin-bottom:16px"><div class="card-body" style="text-align:center;background:#0a0a0a;padding:20px"> <!-- COLUMN 1: METADATA & SPECS -->
<img src="${imgUrl}" style="max-height:300px;border-radius:6px;box-shadow:0 4px 20px rgba(0,0,0,.3)" onerror="this.style.display='none'"> <div class="editor-column scrollable">
</div></div>` : ''} <h4 class="sec-title"><i data-lucide="file-text" style="width:13px;height:13px;color:var(--primary)"></i> Thông số & Mô tả</h4>
<div class="card">
<div class="card-hdr"><span><i data-lucide="list" style="width:14px;height:14px"></i> All Fields</span> <div class="prod-view-header">
<span style="font-size:11px;color:var(--m)">${Object.keys(shortFields).length} fields</span></div> <img class="prod-view-img" src="${imgUrl}" onerror="this.onerror=null; this.src=NO_IMAGE_SVG;">
<div class="card-body" style="padding:0;max-height:500px;overflow-y:auto"> <div class="prod-view-meta">
<table class="field-table"><thead><tr><th>Column</th><th>Value</th></tr></thead><tbody>${tableHtml}</tbody></table> <div class="prod-view-code" style="display:flex; justify-content:space-between; align-items:center;">
<span>${p.magento_ref_code || p.internal_ref_code}</span>
<button class="btn btn-outline" style="padding: 2px 6px; font-size: 10px; display: inline-flex; align-items: center; gap: 4px;" onclick="showRawJson()" title="Xem Raw JSON mô tả">
<i data-lucide="code" style="width: 10px; height: 10px;"></i> JSON
</button>
</div>
<div class="prod-view-title">${p.product_name || '—'}</div>
<div class="prod-view-price">${p.sale_price ? Number(p.sale_price).toLocaleString() + 'đ' : '—'}</div>
<div style="font-size:11px;color:var(--m);margin-top:2px;">Đã bán: <b>${p.quantity_sold ? Math.round(p.quantity_sold) : 0}</b> cái</div>
<div id="anchorSwatches"></div>
</div> </div>
</div> </div>
<div style="background:var(--bg);border:1px solid var(--b);border-radius:8px;padding:8px 12px;margin-bottom:12px">
${specHtml}
</div>
<div class="desc-block-title"><i data-lucide="tag" style="width:12px;height:12px"></i> Danh sách nhãn (Tags)</div>
<div class="badge-grid">
${tagsHtml}
</div>
<div class="desc-block">
<div class="desc-block-title"><i data-lucide="sparkle" style="width:12px;height:12px;color:var(--primary)"></i> Mô tả chính (mo_ta_chinh)</div>
<div style="white-space:pre-wrap">${descData.mo_ta_chinh || p.description_text || 'Chưa tạo mô tả chính sản phẩm.'}</div>
</div>
<div class="desc-block">
<div class="desc-block-title"><i data-lucide="layers" style="width:12px;height:12px"></i> Chất liệu & Đặc điểm vải</div>
<div style="font-weight: 500">${descData.chat_lieu || p.material || 'Thông tin chất liệu trống.'}</div>
<div style="color: var(--m); font-size: 11px; margin-top: 4px">${descData.tinh_nang_vai || p.product_web_material || ''}</div>
</div>
<div class="desc-block" style="border:1px solid rgba(var(--primary-rgb),0.2);background:rgba(var(--primary-rgb),0.02)">
<div class="desc-block-title" style="color:var(--primary)"><i data-lucide="shirt" style="width:12px;height:12px"></i> Ý tưởng phối đồ gốc (Cross-Sell)</div>
<div style="font-weight:600">${descData.cross_sell || 'Chưa ghi nhận ý tưởng phối đồ.'}</div>
</div>
</div> </div>
<div>
<div class="card"> <!-- COLUMN 2: SIMILAR ITEMS -->
<div class="card-hdr"><span><i data-lucide="braces" style="width:14px;height:14px"></i> Raw JSON (all)</span> <div class="editor-column">
<button onclick="copyJson()" style="padding:4px 10px;border:1px solid var(--border);border-radius:4px;background:var(--bg);color:var(--t);cursor:pointer;font-size:11px">Copy</button> <h4 class="sec-title">
<i data-lucide="copy" style="width:13px;height:13px;color:var(--primary)"></i> Sản phẩm tương tự
<span class="badge" id="simCount">0</span>
</h4>
<div class="ai-box">
<div class="ai-box-header">
<div class="ai-box-title"><i data-lucide="sparkles" style="width:12px;height:12px"></i> AI gợi ý tương tự</div>
<button class="ai-btn" onclick="getAISimilarSuggestions()">
<i data-lucide="sparkles" style="width:10px;height:10px"></i> Đề xuất AI
</button>
</div>
<div class="sug-grid" id="simSuggestions">
<div style="color:var(--m);font-size:11px;text-align:center;padding:10px">Bấm nút để chạy gợi ý tương đồng từ CSDL.</div>
</div> </div>
<div class="card-body" style="padding:0"><div class="json-viewer" id="fullJson">${syntaxHighlight(JSON.stringify(p, null, 2))}</div></div>
</div> </div>
${longHtml}
<div class="add-row">
<input type="text" id="addSimSku" placeholder="Nhập mã SKU (ví dụ: 6TS25S017)..." onkeydown="if(event.key==='Enter')addSimilarManual()">
<button onclick="addSimilarManual()">Thêm</button>
</div>
<div id="simListContainer" style="flex:1; overflow-y:auto; min-height:0; padding-right:4px;">
<!-- Rendered similar items list -->
</div>
<div id="simRecycleBin" class="recycle-bin-section"></div>
</div> </div>
</div>`;
// Store for copy <!-- COLUMN 3: OUTFIT RECOMMENDATIONS -->
window._currentProductJson = JSON.stringify(p, null, 2); <div class="editor-column">
<h4 class="sec-title">
<i data-lucide="sparkles" style="width:13px;height:13px;color:var(--primary)"></i> Phối đồ Outfit
<span class="badge" id="outCount">0</span>
</h4>
<div class="ai-box">
<div class="ai-box-header">
<div class="ai-box-title"><i data-lucide="sparkles" style="width:12px;height:12px"></i> AI gợi ý set phối đồ</div>
<button class="ai-btn" onclick="getAIOutfitSuggestions()">
<i data-lucide="sparkles" style="width:10px;height:10px"></i> Phân tích AI
</button>
</div>
<div class="sug-grid" id="outfitSuggestions">
<div style="color:var(--m);font-size:11px;text-align:center;padding:10px">Bấm nút để phân tích cụm từ phối đồ trong bài viết.</div>
</div>
</div>
<div class="add-row">
<input type="text" id="addOutfitSku" placeholder="Nhập mã SKU phối cùng..." onkeydown="if(event.key==='Enter')addOutfitManual()">
<button onclick="addOutfitManual()">Thêm phối đồ</button>
</div>
<div id="outfitListContainer" style="flex:1; overflow-y:auto; min-height:0; padding-right:4px;">
<!-- Rendered outfits list -->
</div>
<div id="outfitRecycleBin" class="recycle-bin-section"></div>
</div>
</div>
`;
lucide.createIcons(); lucide.createIcons();
} catch(e) { renderSimilarItemsList();
console.error(e); renderOutfitsList();
document.getElementById('rightBody').innerHTML = '<div style="color:#ef4444;padding:20px">Error loading detail</div>'; renderRecycleBin();
}
} // Load anchor product color swatches
const anchorSku = p.product_color_code || p.magento_ref_code || p.internal_ref_code;
function copyJson() { if (anchorSku) {
if (window._currentProductJson) { fetchColorVariants(anchorSku).then(variants => {
navigator.clipboard.writeText(window._currentProductJson); const el = document.getElementById('anchorSwatches');
const btn = event.target; if (el && variants.length > 1) {
btn.textContent = 'Copied!'; el.innerHTML = `
setTimeout(() => btn.textContent = 'Copy', 1500); <div style="font-size: 10.5px; color: var(--m); font-weight: 700; text-transform: uppercase; margin-top: 8px; letter-spacing: 0.03em;">
} Biến thể màu sắc khác:
} </div>
`;
// ═══ HELPERS ═══ renderSwatchesForItem(el, anchorSku, variants, (selected) => {
function escapeHtml(s) { // Navigate to the selected color variant smoothly
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); selectColorVariant(selected.product_color_code || selected.magento_ref_code);
} });
}
function syntaxHighlight(json) { });
json = escapeHtml(json); }
return json.replace( }
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?|null)/g,
function(match) { // ═══ SIMILAR ITEMS OPERATIONS ═══
let cls = 'jv-num'; function renderSimilarItemsList() {
if (/^"/.test(match)) { const container = document.getElementById('simListContainer');
cls = /:$/.test(match) ? 'jk' : 'jv-str'; document.getElementById('simCount').textContent = currentSimilarItems.length;
} else if (/true|false/.test(match)) {
cls = 'jv-bool'; if (!currentSimilarItems.length) {
} else if (/null/.test(match)) { container.innerHTML = `
cls = 'jv-null'; <div class="empty-prompt">
<i data-lucide="copy" style="width:28px;height:28px"></i>
<h4>Chưa có sản phẩm tương tự</h4>
<p>Nhập SKU hoặc bấm Đề xuất AI ở trên để chọn thêm sản phẩm tương đồng.</p>
</div>
`;
lucide.createIcons();
return;
}
container.innerHTML = '';
currentSimilarItems.forEach((item, idx) => {
const div = document.createElement('div');
div.className = 'rec-item';
div.innerHTML = `
<img src="${formatImageUrl(item.product_image_url_thumbnail)}" onerror="this.onerror=null; this.src=NO_IMAGE_SVG;">
<div class="rec-item-info">
<div class="rec-item-name" title="${item.product_name || ''}">${item.product_name || 'Thông tin sản phẩm trống'}</div>
<div class="rec-item-code">${item.magento_ref_code || item.sku || '—'} ${item.color_name ? `| ${item.color_name}` : ''}</div>
</div>
<button class="btn-icon" onclick="removeSimilar(${idx})" title="Xóa bỏ">
<i data-lucide="trash-2" style="width:14px;height:14px"></i>
</button>
`;
container.appendChild(div);
});
lucide.createIcons();
}
async function addSimilarManual() {
const input = document.getElementById('addSimSku');
const sku = input.value.trim();
if (!sku) return;
if (currentSimilarItems.some(i => i.sku === sku || i.magento_ref_code === sku)) {
showToast("SKU đã tồn tại trong danh sách tương tự!", "error");
return;
}
try {
const res = await fetch(`/api/manage-ultra-desc/detail/${sku}`);
const data = await res.json();
if (data.status !== 'success') {
showToast(`Không tìm thấy mã sản phẩm ${sku}!`, "error");
return;
}
const p = data.product;
currentSimilarItems.push({
sku: p.internal_ref_code || p.magento_ref_code,
sku_color: p.internal_ref_code || p.magento_ref_code,
color_name: p.product_color_name || '',
magento_ref_code: p.magento_ref_code,
product_name: p.product_name,
product_image_url_thumbnail: p.product_image_url_thumbnail
});
input.value = '';
renderSimilarItemsList();
showToast(`Đã thêm ${sku} vào danh sách tương tự`, "success");
} catch (e) {
console.error(e);
showToast("Gặp lỗi khi thêm SKU", "error");
}
}
function addSimilarObject(item) {
if (currentSimilarItems.some(i => i.magento_ref_code === item.magento_ref_code || i.sku === item.sku)) {
showToast("SKU đã tồn tại!", "error");
return;
}
currentSimilarItems.push(item);
renderSimilarItemsList();
showToast(`Đã thêm ${item.magento_ref_code}`, "success");
}
function removeSimilar(index) {
const deletedItem = currentSimilarItems[index];
currentSimilarItems.splice(index, 1);
renderSimilarItemsList();
addToRecycleBin('similar', deletedItem);
}
async function getAISimilarSuggestions() {
const code = currentProduct.magento_ref_code || currentProduct.internal_ref_code;
const container = document.getElementById('simSuggestions');
container.innerHTML = `
<div style="color:var(--m);font-size:11px;text-align:center;padding:10px">
<i data-lucide="loader" class="spinner" style="width:12px;height:12px;display:inline-block;vertical-align:middle;margin-right:4px"></i>
Đang tìm gợi ý từ AI...
</div>
`;
lucide.createIcons();
try {
const res = await fetch(`/api/manage-ultra-desc/ai-suggest-similar/${code}`, { method: 'POST' });
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
if (!data.suggestions || !data.suggestions.length) {
container.innerHTML = `<div style="color:var(--m);font-size:11px;text-align:center;padding:10px">Không tìm thấy sản phẩm phù hợp.</div>`;
return;
}
container.innerHTML = '';
data.suggestions.forEach(item => {
const div = document.createElement('div');
div.className = 'sug-item';
const itemEscaped = JSON.stringify(item).replace(/"/g, '&quot;');
div.innerHTML = `
<img src="${formatImageUrl(item.product_image_url_thumbnail)}" onerror="this.onerror=null; this.src=NO_IMAGE_SVG;">
<div class="sug-item-name" title="${item.product_name}">${item.product_name}</div>
<span style="font-size:9.5px;color:var(--m);font-family:monospace">${item.magento_ref_code}</span>
<button class="sug-add-btn" onclick="addSimilarObject(${itemEscaped})">+</button>
`;
container.appendChild(div);
});
showToast(`Quét thành công ${data.suggestions.length} gợi ý tương tự (${data.source})`, "info");
} catch (e) {
console.error(e);
container.innerHTML = `<div style="color:var(--red);font-size:11px;text-align:center;padding:10px">Lỗi: ${e.message}</div>`;
}
}
// ═══ OUTFIT RECOMMENDATIONS OPERATIONS ═══
function renderOutfitsList() {
const container = document.getElementById('outfitListContainer');
document.getElementById('outCount').textContent = currentOutfits.length;
if (!currentOutfits.length) {
container.innerHTML = `
<div class="empty-prompt">
<i data-lucide="shirt" style="width:28px;height:28px"></i>
<h4>Chưa phối set đồ nào</h4>
<p>Thêm trực tiếp hoặc chạy phân tích AI trích lọc từ mô tả gốc.</p>
</div>
`;
lucide.createIcons();
return;
}
container.innerHTML = '';
currentOutfits.forEach((item, idx) => {
const div = document.createElement('div');
div.className = 'outfit-item-card';
const bottomSel = item.role === 'bottom' ? 'selected' : '';
const topSel = item.role === 'top' ? 'selected' : '';
const outerSel = item.role === 'outerwear' ? 'selected' : '';
const accSel = item.role === 'accessory' ? 'selected' : '';
div.innerHTML = `
<div class="outfit-item-top">
<img src="${formatImageUrl(item.product_image_url_thumbnail)}" onerror="this.onerror=null; this.src=NO_IMAGE_SVG;">
<div class="outfit-item-meta">
<div class="outfit-item-name" title="${item.match_product_name || ''}">${item.match_product_name || 'Tên phối đồ'}</div>
<div class="outfit-item-sku">${item.sku || '—'} ${item.color_name ? `| ${item.color_name}` : ''}</div>
</div>
<button class="btn-icon" onclick="removeOutfit(${idx})" title="Xóa phối đồ">
<i data-lucide="trash-2" style="width:14px;height:14px"></i>
</button>
</div>
<div class="outfit-item-selects">
<select onchange="updateOutfitRole(${idx}, this.value)" style="font-weight: 600" class="select" style="padding:4px">
<option value="bottom" ${bottomSel}>Bottom (Quần/Váy)</option>
<option value="top" ${topSel}>Top (Áo thun/Sơ mi)</option>
<option value="outerwear" ${outerSel}>Outerwear (Áo khoác)</option>
<option value="accessory" ${accSel}>Accessory (Phụ kiện)</option>
</select>
</div>
<textarea class="outfit-reason-input" placeholder="Lý do phối set (reason)..." onchange="updateOutfitReason(${idx}, this.value)">${item.reason || ''}</textarea>
`;
container.appendChild(div);
});
lucide.createIcons();
}
async function addOutfitManual() {
const input = document.getElementById('addOutfitSku');
const sku = input.value.trim();
if (!sku) return;
if (currentOutfits.some(i => i.sku === sku)) {
showToast("SKU này đã nằm trong set phối đồ!", "error");
return;
}
try {
const res = await fetch(`/api/manage-ultra-desc/detail/${sku}`);
const data = await res.json();
if (data.status !== 'success') {
showToast(`Không tìm thấy mã sản phẩm ${sku}!`, "error");
return;
} }
return `<span class="${cls}">${match}</span>`;
const p = data.product;
currentOutfits.push({
sku: p.internal_ref_code || p.magento_ref_code,
sku_color: p.internal_ref_code || p.magento_ref_code,
color_name: p.product_color_name || '',
match_product_name: p.product_name,
role: "bottom",
reason: `Phối cùng ${p.product_name} giúp tôn lên phong cách trẻ trung và thoải mái khi diện hàng ngày.`,
product_image_url_thumbnail: p.product_image_url_thumbnail
});
input.value = '';
renderOutfitsList();
showToast(`Đã thêm ${sku} vào set đồ`, "success");
} catch (e) {
console.error(e);
showToast("Lỗi thêm phối đồ", "error");
} }
); }
}
function addOutfitObject(item) {
if (currentOutfits.some(i => i.sku === item.sku)) {
showToast("Sản phẩm phối đồ đã có mặt trong set!", "error");
return;
}
currentOutfits.push(item);
renderOutfitsList();
showToast(`Đã thêm ${item.sku} vào set phối đồ`, "success");
}
// ═══ BACK TO STATS ═══ function removeOutfit(index) {
function showStats() { const deletedItem = currentOutfits[index];
currentCode = null; currentOutfits.splice(index, 1);
document.querySelectorAll('.p-item').forEach(e => e.classList.remove('active')); renderOutfitsList();
loadStats(); addToRecycleBin('outfit', deletedItem);
} }
function updateOutfitRole(index, role) {
currentOutfits[index].role = role;
}
function updateOutfitReason(index, reason) {
currentOutfits[index].reason = reason;
}
// ═══ BOOT ═══ async function getAIOutfitSuggestions() {
init(); const code = currentProduct.magento_ref_code || currentProduct.internal_ref_code;
const container = document.getElementById('outfitSuggestions');
container.innerHTML = `
<div style="color:var(--m);font-size:11px;text-align:center;padding:10px">
<i data-lucide="loader" class="spinner" style="width:12px;height:12px;display:inline-block;vertical-align:middle;margin-right:4px"></i>
AI đang phân tích ý tưởng cross-sell...
</div>
`;
lucide.createIcons();
try {
const res = await fetch(`/api/manage-ultra-desc/ai-suggest-outfits/${code}`, { method: 'POST' });
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
if (!data.suggestions || !data.suggestions.length) {
container.innerHTML = `<div style="color:var(--m);font-size:11px;text-align:center;padding:10px">Không trích lọc được sản phẩm phù hợp.</div>`;
return;
}
container.innerHTML = '';
data.suggestions.forEach(item => {
const div = document.createElement('div');
div.className = 'sug-item';
const itemEscaped = JSON.stringify(item).replace(/"/g, '&quot;');
let roleBadge = 'Quần/Váy';
if (item.role === 'outerwear') roleBadge = 'Áo khoác';
if (item.role === 'accessory') roleBadge = 'Phụ kiện';
if (item.role === 'top') roleBadge = 'Áo';
div.innerHTML = `
<img src="${formatImageUrl(item.product_image_url_thumbnail)}" onerror="this.onerror=null; this.src=NO_IMAGE_SVG;">
<div class="sug-item-name" title="${item.match_product_name}">${item.match_product_name}</div>
<span class="chip chip-line" style="font-size:8px;padding:2px 5px">${roleBadge}</span>
<button class="sug-add-btn" onclick="addOutfitObject(${itemEscaped})">+</button>
`;
container.appendChild(div);
});
showToast(`Tìm thấy ${data.suggestions.length} đề xuất phối đồ từ AI`, "info");
} catch (e) {
console.error(e);
container.innerHTML = `<div style="color:var(--red);font-size:11px;text-align:center;padding:10px">Thất bại: ${e.message}</div>`;
}
}
// ═══ SAVE TO POSTGRESQL ═══
async function saveRecommendations() {
const variantCode = currentProduct.product_color_code || currentProduct.magento_ref_code || currentProduct.internal_ref_code;
const baseCode = currentProduct.magento_ref_code || currentProduct.internal_ref_code;
const btn = document.getElementById('btnSave');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `
<i data-lucide="loader" class="spinner" style="width:14px;height:14px"></i>
<span>Đang lưu...</span>
`;
lucide.createIcons();
// Clean payloads
const cleanSimilar = currentSimilarItems.map(item => ({
sku: item.sku || item.magento_ref_code,
sku_color: item.sku_color || item.magento_ref_code,
color_name: item.color_name || '',
magento_ref_code: item.magento_ref_code || item.sku,
product_name: item.product_name
}));
const cleanOutfits = currentOutfits.map(item => ({
sku: item.sku,
sku_color: item.sku_color || item.sku,
color_name: item.color_name || '',
match_product_name: item.match_product_name,
role: item.role || 'bottom',
reason: item.reason || ''
}));
try {
const res = await fetch(`/api/manage-ultra-desc/update/${variantCode}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
similar_items: cleanSimilar,
outfit_recommendations: cleanOutfits
})
});
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
showToast(`Lưu đề xuất thành công cho sản phẩm ${variantCode}!`, "success");
// Update sidebar state badges locally instead of reloading the whole list
const hasSim = currentSimilarItems.length > 0;
const hasOutfit = currentOutfits.length > 0;
updateSidebarItem(baseCode, hasSim, hasOutfit);
} catch (e) {
console.error(e);
showToast(`Không thể lưu: ${e.message}`, "error");
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
lucide.createIcons();
}
}
// ═══ BOOT ═══
init();
</script> </script>
</body> </body>
</html> </html>
/* Modal Raw JSON style */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
}
.modal-overlay.show {
opacity: 1;
pointer-events: auto;
}
.modal-container {
background: var(--s);
border: 1px solid var(--b);
border-radius: 12px;
width: 90%;
max-width: 650px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-2xl);
transform: scale(0.95);
transition: transform 0.25s ease;
}
.modal-overlay.show .modal-container {
transform: scale(1);
}
.modal-header {
padding: 16px 20px;
border-bottom: 1px solid var(--b);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--t);
display: flex;
align-items: center;
gap: 8px;
}
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.modal-body pre {
background: #1e1e1e;
color: #9cdcfe;
padding: 16px;
border-radius: 8px;
font-family: 'Consolas', 'Courier New', monospace;
font-size: 12px;
line-height: 1.5;
margin: 0;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
.modal-footer {
padding: 12px 20px;
border-top: 1px solid var(--b);
display: flex;
justify-content: flex-end;
gap: 10px;
}
/**
* JSON Viewer Popup Module
* Handles displaying raw description JSON in a clean overlay modal.
*/
// Inject the modal HTML into body automatically on load
document.addEventListener("DOMContentLoaded", () => {
const modalHtml = `
<div class="modal-overlay" id="jsonModalOverlay" onclick="closeJsonModal()">
<div class="modal-container" onclick="event.stopPropagation()">
<div class="modal-header">
<h3><i data-lucide="code" style="color:var(--primary)"></i> Raw JSON mô tả sản phẩm</h3>
<button class="btn-icon" onclick="closeJsonModal()"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<pre id="jsonPreCode">{}</pre>
</div>
<div class="modal-footer">
<button class="btn btn-outline btn-sm" onclick="copyJsonText()">
<i data-lucide="copy" style="width:14px;height:14px"></i> Copy JSON
</button>
<button class="btn btn-primary btn-sm" onclick="closeJsonModal()">Đóng</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
if (window.lucide) {
window.lucide.createIcons();
}
});
function showRawJson() {
if (!window.currentProduct) return;
const pre = document.getElementById('jsonPreCode');
let rawStr = window.currentProduct.description_data_for_production || '{}';
try {
const obj = JSON.parse(rawStr);
rawStr = JSON.stringify(obj, null, 2);
} catch (e) {
// Show raw text if parse fails
}
pre.textContent = rawStr;
document.getElementById('jsonModalOverlay').classList.add('show');
if (window.lucide) {
window.lucide.createIcons();
}
}
function closeJsonModal() {
document.getElementById('jsonModalOverlay').classList.remove('show');
}
function copyJsonText() {
const text = document.getElementById('jsonPreCode').textContent;
navigator.clipboard.writeText(text).then(() => {
if (typeof window.showToast === 'function') {
window.showToast("Đã copy Raw JSON vào clipboard!", "success");
} else {
alert("Đã copy Raw JSON vào clipboard!");
}
}).catch(err => {
if (typeof window.showToast === 'function') {
window.showToast("Không thể copy! Hãy bôi đen để copy thủ công.", "error");
} else {
alert("Không thể copy!");
}
});
}
...@@ -100,6 +100,7 @@ ...@@ -100,6 +100,7 @@
<script> <script>
if (window.lucide) { lucide.createIcons(); } if (window.lucide) { lucide.createIcons(); }
const NO_IMAGE_SVG = `data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="130" viewBox="0 0 100 130"><rect width="100" height="130" rx="6" fill="%23E2E0D8"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="10" font-weight="600" fill="%238C8A82">NO IMAGE</text></svg>`;
let currentProductCode = null; let currentProductCode = null;
let currentDescriptionData = null; let currentDescriptionData = null;
let currentTags = []; let currentTags = [];
...@@ -121,7 +122,7 @@ async function fetchMissingTags() { ...@@ -121,7 +122,7 @@ async function fetchMissingTags() {
div.className = 'fm-item has-image'; div.className = 'fm-item has-image';
div.onclick = () => loadProduct(p.code, p.product_name, div); div.onclick = () => loadProduct(p.code, p.product_name, div);
div.innerHTML = ` div.innerHTML = `
<img src="${p.product_image_url}" alt="" onerror="this.src='/static/common/img/placeholder.png'"> <img src="${p.product_image_url}" alt="" onerror="this.onerror=null; this.src=NO_IMAGE_SVG;">
<div class="fm-item-info"> <div class="fm-item-info">
<div class="fm-title">${p.code}</div> <div class="fm-title">${p.code}</div>
<div class="fm-desc-sm">${p.product_name}</div> <div class="fm-desc-sm">${p.product_name}</div>
...@@ -161,7 +162,7 @@ async function loadProduct(code, name, el) { ...@@ -161,7 +162,7 @@ async function loadProduct(code, name, el) {
data.similar_items.forEach(s => { data.similar_items.forEach(s => {
simHtml += ` simHtml += `
<div class="similar-item"> <div class="similar-item">
<img src="${s.product_image_url}" alt="" onerror="this.src='/static/common/img/placeholder.png'"> <img src="${s.product_image_url}" alt="" onerror="this.onerror=null; this.src=NO_IMAGE_SVG;">
<div style="flex:1; overflow:hidden;"> <div style="flex:1; overflow:hidden;">
<div style="font-weight:600; font-size:12px;">${s.magento_ref_code}</div> <div style="font-weight:600; font-size:12px;">${s.magento_ref_code}</div>
<div style="font-size:11px; color:var(--m); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${s.product_name}</div> <div style="font-size:11px; color:var(--m); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${s.product_name}</div>
......
...@@ -86,10 +86,10 @@ ...@@ -86,10 +86,10 @@
"accessory": 4 "accessory": 4
}, },
"role_max_items": { "role_max_items": {
"top": 3, "top": 6,
"bottom": 3, "bottom": 6,
"outerwear": 2, "outerwear": 4,
"accessory": 2, "accessory": 4,
"underwear": 2 "underwear": 2
}, },
"_comment_product_line_to_role": "Ánh xạ tên product_line_vn → role trong outfit", "_comment_product_line_to_role": "Ánh xạ tên product_line_vn → role trong outfit",
......
...@@ -20,6 +20,7 @@ from common.constants import ( ...@@ -20,6 +20,7 @@ from common.constants import (
KID_SAFE_KEYWORDS, KID_SAFE_KEYWORDS,
TABLE_PRODUCTS, TABLE_PRODUCTS,
TABLE_OUTFIT_MATCHES, TABLE_OUTFIT_MATCHES,
TABLE_OUTFIT_RULES,
normalize_gender, normalize_gender,
) )
...@@ -52,25 +53,25 @@ class StylistEngine: ...@@ -52,25 +53,25 @@ class StylistEngine:
# Public API # Public API
# ────────────────────────────────────────── # ──────────────────────────────────────────
def run_for_code(self, code: str) -> dict: def run_for_code(self, code: str, by_color: bool = False) -> dict:
"""Compute ai_matches for a single product code and save to DB.""" """Compute ai_matches for a single product code and save to DB."""
catalog = self._get_catalog() catalog = self._get_catalog(by_color=by_color)
source = next((p for p in catalog if p["code"] == code), None) source = next((p for p in catalog if p["code"] == code), None)
if not source: if not source:
logger.warning("[Stylist] Product not found in catalog: %s", code) logger.warning("[Stylist] Product not found in catalog: %s", code)
return {} return {}
matches = self.compute_dynamic_rule_matches(code) matches = self.compute_dynamic_rule_matches(code, by_color=by_color)
self._save_matches(code, matches) self._save_matches(code, matches, by_color=by_color)
logger.info("[Stylist] ✅ %s → %d matches across occasions", code, sum( logger.info("[Stylist] ✅ %s → %d matches across occasions", code, sum(
len(items) for occ in matches.values() for items in occ.values() len(items) for occ in matches.values() for items in occ.values()
)) ))
return matches return matches
def run_batch(self, codes: list[str] | None = None) -> int: def run_batch(self, codes: list[str] | None = None, by_color: bool = False) -> int:
"""Batch run for list of codes (or all if None). Updates BATCH_STATE.""" """Batch run for list of codes (or all if None). Updates BATCH_STATE."""
global BATCH_STATE global BATCH_STATE
catalog = self._get_catalog(force_reload=True) catalog = self._get_catalog(force_reload=True, by_color=by_color)
targets = codes or [p["code"] for p in catalog] targets = codes or [p["code"] for p in catalog]
BATCH_STATE.update({ BATCH_STATE.update({
...@@ -87,8 +88,8 @@ class StylistEngine: ...@@ -87,8 +88,8 @@ class StylistEngine:
for code in targets: for code in targets:
BATCH_STATE["current_code"] = code BATCH_STATE["current_code"] = code
try: try:
matches = self.compute_dynamic_rule_matches(code) matches = self.compute_dynamic_rule_matches(code, by_color=by_color)
self._save_matches(code, matches) self._save_matches(code, matches, by_color=by_color)
count += 1 count += 1
BATCH_STATE["done"] += 1 BATCH_STATE["done"] += 1
except Exception as e: except Exception as e:
...@@ -190,9 +191,9 @@ class StylistEngine: ...@@ -190,9 +191,9 @@ class StylistEngine:
def compute_dynamic_rule_matches(self, code: str) -> dict: def compute_dynamic_rule_matches(self, code: str, by_color: bool = False) -> dict:
"""Dynamically compute matches based on chatbot_fashion_rules (DB) with color+material+role scoring.""" """Dynamically compute matches based on chatbot_fashion_rules (DB) with color+material+role scoring."""
catalog = self._get_catalog() catalog = self._get_catalog(by_color=by_color)
source = next((p for p in catalog if p["code"] == code), None) source = next((p for p in catalog if p["code"] == code), None)
if not source: if not source:
logger.warning("[Stylist] Product not found in catalog: %s", code) logger.warning("[Stylist] Product not found in catalog: %s", code)
...@@ -233,7 +234,7 @@ class StylistEngine: ...@@ -233,7 +234,7 @@ class StylistEngine:
# Check each occasion's rules # Check each occasion's rules
for occ, rules_list in rules_by_occ.items(): for occ, rules_list in rules_by_occ.items():
for r in rules_list: for r in rules_list:
if tgt_pl.lower() == r["target_cat"].lower() and tgt_role == r["role"]: if tgt_pl.lower() == r["target_cat"].lower():
# Compute score using new simplified scoring # Compute score using new simplified scoring
score = self._score(source, target, occ) score = self._score(source, target, occ)
if score < self.rules.get("min_score", 35): if score < self.rules.get("min_score", 35):
...@@ -673,100 +674,94 @@ class StylistEngine: ...@@ -673,100 +674,94 @@ class StylistEngine:
return 12 return 12
return 6 return 6
def _clean_product_name(self, name: str) -> str:
if not name:
return ""
# Remove common technical suffixes and words for a cleaner natural look
words_to_strip = [
r"\btrẻ em\b", r"\bbé trai\b", r"\bbé gái\b", r"\bnam\b", r"\bnữ\b",
r"\bcotton\b", r"\bsoft\b", r"\bcool\b", r"\bpremium\b", r"\bdài tay\b",
r"\bngắn tay\b", r"\bsát nách\b", r"\bco giãn\b", r"\bmềm mại\b", r"\bnhẹ nhàng\b"
]
import re
cleaned = name
for pattern in words_to_strip:
cleaned = re.sub(pattern, "", cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
if cleaned:
cleaned = cleaned[0].upper() + cleaned[1:]
return cleaned
def _build_reason(self, src: dict, tgt: dict, occasion: str, score: int) -> str: def _build_reason(self, src: dict, tgt: dict, occasion: str, score: int) -> str:
"""Generate varied Vietnamese styling reason — stylist tone, not template.""" """Generate varied Vietnamese styling reason — stylist tone, not template."""
src_name_raw = src.get("name") or src.get("product_name") or ""
tgt_name_raw = tgt.get("name") or tgt.get("product_name") or ""
src_name = self._clean_product_name(src_name_raw)
tgt_name = self._clean_product_name(tgt_name_raw)
src_color = src.get("color") or ""
tgt_color = tgt.get("color") or ""
# Generate contextual outfit reasons based on item categories
if any(w in src_name.lower() or w in tgt_name.lower() for w in ["mặc nhà", "ở nhà", "ngủ"]):
reason = f"Màu {tgt_color} của {tgt_name} phối cùng màu {src_color} tạo điểm nhấn hoàn hảo. {src_name} phối {tgt_name} — thoải mái tại nhà."
elif any(w in src_name.lower() or w in tgt_name.lower() for w in ["len", "nỉ", "hoodie", "khoác", "ấm"]):
reason = f"Màu {tgt_color} của {tgt_name} phối cùng màu {src_color} tạo điểm nhấn ấm áp, nổi bật. {src_name} phối {tgt_name} — giữ ấm hoàn hảo ngày lạnh."
elif any(w in src_name.lower() or w in tgt_name.lower() for w in ["jean", "polo", "phông", "kaki", "short", "soóc"]):
reason = f"Màu {tgt_color} của {tgt_name} phối cùng màu {src_color} tạo điểm nhấn trẻ trung. {src_name} phối {tgt_name} — năng động, tự tin xuống phố."
else:
reason = f"Màu {tgt_color} của {tgt_name} phối cùng màu {src_color} tạo điểm nhấn hài hòa. {src_name} phối {tgt_name} — phong cách thời trang hiện đại."
return reason
sc = self._detect_color_key(src.get("color", ""), self.rules["color_keys"])
tc = self._detect_color_key(tgt.get("color", ""), self.rules["color_keys"])
role = self._get_role(tgt)
occ_label = self.rules.get("occasion_labels", {}).get(occasion, occasion)
src_pl = src.get("product_line", "")
tgt_pl = tgt.get("product_line", "")
# ── Color phrasing library ──
color_phrases = []
if sc and tc:
if sc == tc:
color_phrases = [
f"tone {sc.lower()} đồng điệu tạo set đồ liền mạch",
f"cùng gam {sc.lower()} cho diện mạo thống nhất",
f"giữ nguyên tone {sc.lower()} – thanh lịch, không rối mắt",
]
else:
color_phrases = [
f"phối {sc.lower()} + {tc.lower()} cân bằng sáng – tối",
f"gam {sc.lower()} kết hợp {tc.lower()} tạo điểm nhấn",
f"tông {tc.lower()} bắt sáng trên nền {sc.lower()}",
f"mix {sc.lower()} và {tc.lower()} – clean nhưng không nhàm",
]
# ── Role-specific phrasing ──
role_phrases = {
"top": [
f"áo phối chuẩn tỉ lệ với {src_pl}",
f"dáng áo ôm nhẹ balance với {src_pl}",
f"form áo gọn – tôn dáng khi mặc cùng {src_pl}",
],
"bottom": [
"quần/váy giữ silhouette cân đối",
"phom dáng dưới hợp dáng trên – tạo tỉ lệ đẹp",
"item dưới chuẩn form – không bị chìm khi phối",
],
"outerwear": [
"layer ngoài hoàn thiện set – thêm chiều sâu",
"khoác nhẹ nâng tầm outfit, che khuyết điểm tốt",
"layer phủ ngoài giúp set đồ có điểm nhấn",
],
"accessory": [
"phụ kiện điểm nhấn – nâng outfit lên level mới",
"chi tiết nhỏ nhưng tạo khác biệt rõ rệt",
"phụ kiện giúp set đồ trông hoàn chỉnh hơn",
],
}
# ── Occasion-specific tips ──
occ_tips = {
"di_lam": ["gọn gàng, chuyên nghiệp", "thanh lịch nơi công sở", "lịch sự mà không quá cứng nhắc"],
"di_choi": ["thoải mái, trẻ trung", "năng động mà vẫn sành điệu", "cuốn hút khi dạo phố"],
"hang_ngay": ["dễ mặc, dễ phối", "đơn giản nhưng không đơn điệu", "tiện lợi cho mọi hoạt động"],
"du_lich": ["gọn nhẹ, dễ di chuyển", "thoáng mát, tiện cho chuyến đi", "phong cách du lịch thoải mái"],
"the_thao": ["thoáng khí, co giãn tốt", "năng động, thoải mái vận động", "sporty nhưng vẫn có gu"],
"di_tiec": ["sang trọng, nổi bật", "thanh lịch cho sự kiện", "tạo ấn tượng mạnh"],
"mac_nha": ["mềm mại, thoải mái", "dễ chịu khi ở nhà", "relax tối đa"],
}
# ── Build final reason ──
parts = []
if color_phrases:
parts.append(random.choice(color_phrases))
if role and role in role_phrases:
parts.append(random.choice(role_phrases[role]))
if occ_label and occasion in occ_tips:
parts.append(random.choice(occ_tips[occasion]))
if not parts:
return f"Phù hợp phối cùng – điểm tương thích {score}/100"
return ". ".join(parts[:2]).capitalize()
def _deduplicate(self, items: list[dict], max_n: int) -> list[dict]: def _deduplicate(self, items: list[dict], max_n: int) -> list[dict]:
"""Take top N items by score (already sorted). Simple and effective.""" """
return items[:max_n] Take top N items by score while ensuring diversity of product_line.
Ensures pants, shorts, skirts etc. get a fair chance to be recommended.
"""
if not items:
return []
import collections
# Group items by product_line, preserving original score order (since items is sorted)
by_pl = collections.defaultdict(list)
for item in items:
pl = item.get("product_line", "")
by_pl[pl].append(item)
# Order product lines by the highest score of their top items
pl_ordered = sorted(by_pl.keys(), key=lambda pl: by_pl[pl][0]["score"], reverse=True)
diverse_list = []
idx = 0
while len(diverse_list) < max_n:
added_in_round = False
for pl in pl_ordered:
if idx < len(by_pl[pl]):
diverse_list.append(by_pl[pl][idx])
added_in_round = True
if len(diverse_list) == max_n:
break
if not added_in_round:
break
idx += 1
return diverse_list
# ────────────────────────────────────────── # ──────────────────────────────────────────
# Data Loading # Data Loading
# ────────────────────────────────────────── # ──────────────────────────────────────────
def _get_catalog(self, force_reload: bool = False) -> list[dict]: def _get_catalog(self, force_reload: bool = False, by_color: bool = False) -> list[dict]:
"""Load product catalog from PostgreSQL only (offline mode).""" """Load product catalog from PostgreSQL only (offline mode)."""
if self._catalog and not force_reload: cache_attr = "_catalog_by_color" if by_color else "_catalog_by_base"
return self._catalog cached_val = getattr(self, cache_attr, None)
if cached_val and not force_reload:
return cached_val
logger.info("[Stylist] Loading catalog from PostgreSQL...") logger.info("[Stylist] Loading catalog from PostgreSQL (by_color=%s)...", by_color)
conn = None conn = None
try: try:
from common.pool_wrapper import get_pooled_connection_compat from common.pool_wrapper import get_pooled_connection_compat
...@@ -775,9 +770,12 @@ class StylistEngine: ...@@ -775,9 +770,12 @@ class StylistEngine:
cur = conn.cursor(row_factory=dict_row) cur = conn.cursor(row_factory=dict_row)
# Fetch product basics from Postgres # Fetch product basics from Postgres
code_col = "COALESCE(product_color_code, magento_ref_code)" if by_color else "magento_ref_code"
query = f""" query = f"""
SELECT magento_ref_code as code, SELECT {code_col} as code,
magento_ref_code as base_code,
product_name as name, product_name as name,
product_color_name as color_name,
master_color as color, master_color as color,
product_line_vn as product_line, product_line_vn as product_line,
gender_by_product as gender, gender_by_product as gender,
...@@ -791,12 +789,13 @@ class StylistEngine: ...@@ -791,12 +789,13 @@ class StylistEngine:
rows = cur.fetchall() rows = cur.fetchall()
cur.close() cur.close()
# Build catalog (no style/occasion tags from ultra_descriptions to avoid Postgres)
catalog = [] catalog = []
for row in rows: for row in rows:
catalog.append({ catalog.append({
"code": row["code"], "code": row["code"],
"base_code": row["base_code"],
"name": row["name"] or "", "name": row["name"] or "",
"color_name": row["color_name"] or "",
"color": row["color"] or "", "color": row["color"] or "",
"product_line": row["product_line"] or "", "product_line": row["product_line"] or "",
"gender": row["gender"] or "", "gender": row["gender"] or "",
...@@ -808,8 +807,8 @@ class StylistEngine: ...@@ -808,8 +807,8 @@ class StylistEngine:
"season_tags": [], "season_tags": [],
}) })
self._catalog = catalog setattr(self, cache_attr, catalog)
logger.info("[Stylist] Catalog loaded: %d products", len(catalog)) logger.info("[Stylist] Catalog loaded: %d products (by_color=%s)", len(catalog), by_color)
return catalog return catalog
except Exception as e: except Exception as e:
logger.error("[Stylist] Error loading catalog: %s", e) logger.error("[Stylist] Error loading catalog: %s", e)
...@@ -837,8 +836,8 @@ class StylistEngine: ...@@ -837,8 +836,8 @@ class StylistEngine:
# DB Write # DB Write
# ────────────────────────────────────────── # ──────────────────────────────────────────
def _save_matches(self, code: str, matches: dict) -> bool: def _save_matches(self, code: str, matches: dict, by_color: bool = False) -> bool:
"""Save matches to ai_outfit_product_matches table.""" """Save matches to ai_outfit_product_matches table and update outfit_recommendations in products table."""
conn = None conn = None
try: try:
from common.pool_wrapper import get_pooled_connection_compat from common.pool_wrapper import get_pooled_connection_compat
...@@ -846,7 +845,7 @@ class StylistEngine: ...@@ -846,7 +845,7 @@ class StylistEngine:
cur = conn.cursor() cur = conn.cursor()
# Get full catalog to look up target details (gender, age) # Get full catalog to look up target details (gender, age)
catalog = self._get_catalog() catalog = self._get_catalog(by_color=by_color)
source = next((p for p in catalog if p["code"] == code), None) source = next((p for p in catalog if p["code"] == code), None)
if not source: if not source:
logger.error("[Stylist] Anchor product not found: %s", code) logger.error("[Stylist] Anchor product not found: %s", code)
...@@ -857,36 +856,83 @@ class StylistEngine: ...@@ -857,36 +856,83 @@ class StylistEngine:
for occ, occ_data in matches.items(): for occ, occ_data in matches.items():
for role, items in occ_data.items(): for role, items in occ_data.items():
for item in items: for item in items:
# Skip items without rule_id (fallback path)
if item.get("rule_id") is None: if item.get("rule_id") is None:
continue continue
# Find target product in catalog to get gender and age
target = next((p for p in catalog if p["code"] == item["code"]), None) target = next((p for p in catalog if p["code"] == item["code"]), None)
if not target: if not target:
continue continue
# Note: Postgres column name is magento_ref_code instead of anchor_product_code!
# occasion in database is text, we serialize to JSON list format to match API standard: e.g. ["di_lam"]
occ_json = json.dumps([occ], ensure_ascii=False)
rows.append(( rows.append((
item["rule_id"], item["rule_id"],
code, # anchor_product_code code, # magento_ref_code (could be product_color_code or base code)
item["code"], # match_product_code item["code"], # match_product_code (could be product_color_code or base code)
item["name"], # match_product_name item["name"], # match_product_name
target.get("gender", ""), # match_gender target.get("gender", ""), # match_gender
target.get("age_group", ""), # match_age target.get("age_group", ""), # match_age
item["role"], # match_role item["role"], # match_role
item["score"], float(item["score"]),
item["reason"], item["reason"],
item["occasion"], occ_json, # occasion JSON string
)) ))
# 1. Update ai_outfit_product_matches
# Delete old matches for this anchor product to avoid duplication
cur.execute(f"DELETE FROM {TABLE_OUTFIT_MATCHES} WHERE magento_ref_code = %s", (code,))
if rows: if rows:
cur.executemany(f''' cur.executemany(f'''
INSERT INTO {TABLE_OUTFIT_MATCHES} INSERT INTO {TABLE_OUTFIT_MATCHES}
(rule_id, anchor_product_code, match_product_code, match_product_name, match_gender, match_age, match_role, score, ai_reason, occasion) (rule_id, magento_ref_code, match_product_code, match_product_name, match_gender, match_age, match_role, score, ai_reason, occasion)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
''', rows) ''', rows)
conn.commit()
logger.info("[Stylist] Saved %d matches for %s", len(rows), code) # 2. Update outfit_recommendations in products table (magento_product_dimension_with_text_embedding)
else: # Build flat list of recommendations for the JSON column
logger.debug("[Stylist] No matches to save for %s", code) flat_outfits = []
role_items = {}
for occ, occ_data in matches.items():
for role, items in occ_data.items():
role_items.setdefault(role, [])
role_items[role].extend(items)
for role, items in role_items.items():
seen = {}
for item in items:
c_code = item["code"]
if c_code not in seen or item["score"] > seen[c_code]["score"]:
seen[c_code] = item
# Take top 3 per role
sorted_role_items = sorted(seen.values(), key=lambda x: -x["score"])[:3]
for item in sorted_role_items:
target = next((p for p in catalog if p["code"] == item["code"]), None)
color_name = target.get("color_name") if target else item.get("color", "")
flat_outfits.append({
"sku": item["code"],
"sku_color": item["code"],
"color_name": color_name or item.get("color", ""),
"match_product_name": item["name"],
"role": role,
"reason": item["reason"],
"product_image_url_thumbnail": item.get("image", "")
})
outfits_json = json.dumps(flat_outfits, ensure_ascii=False)
# Update target product's outfit_recommendations JSON
cur.execute(f"""
UPDATE {TABLE_PRODUCTS}
SET outfit_recommendations = %s
WHERE product_color_code = %s OR (product_color_code IS NULL AND magento_ref_code = %s)
""", (outfits_json, code, code))
conn.commit()
logger.info("[Stylist] Saved %d matches and updated outfit_recommendations for %s", len(rows), code)
return True return True
except Exception as e: except Exception as e:
logger.error("[Stylist] Save matches error: %s", e) logger.error("[Stylist] Save matches error: %s", e)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment