diff --git a/config.py b/config.py index 8beafcf..ed19d6c 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.107" +SYSTEM_VERSION = "V10.108" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/routes/sales_routes.py b/routes/sales_routes.py index 7cb771e..756f587 100644 --- a/routes/sales_routes.py +++ b/routes/sales_routes.py @@ -26,6 +26,8 @@ from services.logger_manager import SystemLogger from services.daily_sales_service import prepare_marketing_summary from services.cache_manager import ( _SALES_PROCESSED_CACHE, + _SALES_OPTIONS_CACHE, + _SALES_ANALYSIS_RESULT_CACHE, set_sales_processed_cache, ) from utils.text_helpers import get_color_for_string @@ -41,6 +43,10 @@ sales_bp = Blueprint('sales', __name__) _TABLE_DATA_CACHE = {} _TABLE_DATA_CACHE_TTL = 60 +_SALES_PREVIEW_CACHE_TTL = 600 +_SALES_OPTIONS_CACHE_TTL = 1800 +_SALES_PAGE_CONTEXT_CACHE_TTL = 180 +_SALES_PAGE_CONTEXT_CACHE_MAX = 24 # ========================================== @@ -52,6 +58,214 @@ from utils.df_helpers import find_col # noqa: E402, F401 from utils.security import validate_table_name, safe_read_sql # noqa: E402, F401 +def _get_timed_cache(store, key, ttl_seconds): + entry = store.get(key) + if not entry: + return None + if time.time() - entry.get('time', 0) >= ttl_seconds: + store.pop(key, None) + return None + return entry.get('data') + + +def _set_timed_cache(store, key, data, max_entries=64): + if len(store) >= max_entries: + oldest_key = min(store, key=lambda k: store[k].get('time', 0)) + store.pop(oldest_key, None) + store[key] = {'data': data, 'time': time.time()} + + +def _sales_analysis_args_fingerprint(table_name, cache_key, processed_time=None): + args = tuple( + (key, tuple(values)) + for key, values in sorted(request.args.lists()) + ) + raw = repr((table_name, cache_key, processed_time or 0, args)).encode('utf-8') + return hashlib.md5(raw).hexdigest() + + +def _get_sales_page_context_cache(cache_key): + return _get_timed_cache( + _SALES_ANALYSIS_RESULT_CACHE, + cache_key, + _SALES_PAGE_CONTEXT_CACHE_TTL, + ) + + +def _set_sales_page_context_cache(cache_key, context): + _set_timed_cache( + _SALES_ANALYSIS_RESULT_CACHE, + cache_key, + context, + max_entries=_SALES_PAGE_CONTEXT_CACHE_MAX, + ) + + +def _format_sales_data_range(min_date, max_date): + if not min_date or not max_date: + return '' + try: + if isinstance(min_date, str): + min_date_obj = datetime.strptime(min_date.split()[0], '%Y-%m-%d') + max_date_obj = datetime.strptime(max_date.split()[0], '%Y-%m-%d') + else: + min_date_obj = min_date + max_date_obj = max_date + return f"{min_date_obj.year}年{min_date_obj.month}月 ~ {max_date_obj.year}年{max_date_obj.month}月" + except Exception: + return f"{min_date} ~ {max_date}" + + +def _fetch_sales_data_range(engine, table_name): + validate_table_name(table_name) + cache_key = f"sales_analysis:data_range:{table_name}" + cached = _get_timed_cache(_SALES_OPTIONS_CACHE, cache_key, _SALES_PREVIEW_CACHE_TTL) + if cached is not None: + return cached + + try: + date_query = text(f'SELECT MIN("日期") as min_date, MAX("日期") as max_date FROM "{table_name}"') + with engine.connect() as conn: + result = conn.execute(date_query).fetchone() + db_data_range = _format_sales_data_range(result[0], result[1]) if result else '' + except Exception as e: + sys_log.warning(f"[Sales Analysis] 無法取得資料期間範圍: {e}") + db_data_range = '' + + _set_timed_cache(_SALES_OPTIONS_CACHE, cache_key, db_data_range) + return db_data_range + + +def _preview_sales_filter_options(engine, table_name): + """首次進入業績分析時的輕量下拉資料,避免每次載入都掃預覽資料。""" + validate_table_name(table_name) + cache_key = f"sales_analysis:preview_options:{table_name}" + cached = _get_timed_cache(_SALES_OPTIONS_CACHE, cache_key, _SALES_PREVIEW_CACHE_TTL) + if cached: + return cached + + options = { + 'categories': [], + 'brands': [], + 'vendors': [], + 'activities': [], + 'payments': [], + 'months': [], + } + preview_df = pd.read_sql(f'SELECT * FROM "{table_name}" LIMIT 1000', engine) + if preview_df.empty: + _set_timed_cache(_SALES_OPTIONS_CACHE, cache_key, options) + return options + + cols = preview_df.columns.tolist() + + def find_preview_col(keywords): + for k in keywords: + for col in cols: + if k in str(col): + return col + return None + + col_category = find_preview_col(['館別', '商品館', '分類', 'Category']) + col_brand = find_preview_col(['品牌', 'Brand']) + col_vendor = find_preview_col(['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier']) + col_activity = find_preview_col(['折扣活動名稱', '折價券活動名稱', '滿額再折扣活動名稱', '活動', 'Activity', 'Campaign']) + col_payment = find_preview_col(['付款', 'Payment', 'Pay']) + col_date_part = find_preview_col(['日期', '交易日期', 'Date', 'Day']) + + def clean_values(col): + if not col: + return [] + return sorted([ + x for x in preview_df[col].dropna().astype(str).unique().tolist() + if x and x.strip() + ]) + + options['categories'] = clean_values(col_category) + options['brands'] = clean_values(col_brand) + options['vendors'] = clean_values(col_vendor) + options['activities'] = clean_values(col_activity) + options['payments'] = clean_values(col_payment) + + if col_date_part: + try: + with engine.connect() as conn: + result = conn.execute(text(f""" + SELECT DISTINCT replace(substr("{col_date_part}", 1, 7), '/', '-') as month + FROM "{table_name}" + WHERE "{col_date_part}" IS NOT NULL AND "{col_date_part}" != '' + ORDER BY month + """)).fetchall() + options['months'] = [row[0] for row in result if row[0] and '-' in str(row[0])] + sys_log.info(f"[Sales Analysis] 預覽模式從「{col_date_part}」欄位提取到 {len(options['months'])} 個月份") + except Exception as e: + sys_log.warning(f"[Sales Analysis] 無法從「{col_date_part}」欄位提取月份: {e}") + + _set_timed_cache(_SALES_OPTIONS_CACHE, cache_key, options) + return options + + +def _get_sales_filter_options(engine, table_name, cols_map): + """完整篩選選項共用快取;資料匯入後 clear_sales_cache() 會一起清掉。""" + validate_table_name(table_name) + col_category = cols_map.get('category') + col_brand = cols_map.get('brand') + col_vendor = cols_map.get('vendor') + col_activity = cols_map.get('activity') + col_payment = cols_map.get('payment') + col_date = cols_map.get('date') + option_cols = (col_category, col_brand, col_vendor, col_activity, col_payment, col_date) + digest = hashlib.md5(repr(option_cols).encode('utf-8')).hexdigest() + cache_key = f"sales_analysis:filter_options:{table_name}:{digest}" + cached = _get_timed_cache(_SALES_OPTIONS_CACHE, cache_key, _SALES_OPTIONS_CACHE_TTL) + if cached: + return cached + + options = { + 'categories': [], + 'brands': [], + 'vendors': [], + 'activities': [], + 'payments': [], + 'months': [], + } + + def read_distinct(conn, col): + if not col: + return [] + sql = f'SELECT DISTINCT "{col}" FROM "{table_name}" WHERE "{col}" IS NOT NULL AND "{col}" <> \'\' ORDER BY "{col}"' + result = conn.execute(text(sql)).fetchall() + return [str(row[0]) for row in result if row[0]] + + with engine.connect() as conn: + options['categories'] = read_distinct(conn, col_category) + options['brands'] = read_distinct(conn, col_brand) + options['vendors'] = read_distinct(conn, col_vendor) + options['activities'] = read_distinct(conn, col_activity) + options['payments'] = read_distinct(conn, col_payment) + + if col_date: + date_fields = ['日期', '訂單日期', '時間'] + for field in date_fields: + try: + result = conn.execute(text(f""" + SELECT DISTINCT replace(substr("{field}", 1, 7), '/', '-') as month + FROM "{table_name}" + WHERE "{field}" IS NOT NULL AND "{field}" != '' + ORDER BY month + """)).fetchall() + months = [row[0] for row in result if row[0] and '-' in str(row[0])] + if months: + options['months'] = months + sys_log.info(f"[Sales Analysis] 從欄位 {field} 提取到 {len(months)} 個月份: {months}") + break + except Exception as ex: + sys_log.warning(f"[Sales Analysis] 從欄位 {field} 提取月份失敗: {ex}") + + _set_timed_cache(_SALES_OPTIONS_CACHE, cache_key, options) + return options + + def _growth_empty_payload(now_taipei=None): now_taipei = now_taipei or datetime.now(TAIPEI_TZ) return ( @@ -558,7 +772,7 @@ def sales_analysis(): # 1. 檢查資料表是否存在 inspector = inspect(db.engine) - if table_name not in inspector.get_table_names(): + if not inspector.has_table(table_name): return render_template('sales_analysis.html', error="尚未匯入「即時業績(全月)」資料,請先至設定頁面匯入 Excel。", table_name=table_name, @@ -571,31 +785,8 @@ def sales_analysis(): active_page='sales', db_data_range='') - # V-New: 查詢資料庫的資料期間範圍 - db_data_range = '' - try: - # 取得日期欄位的最小值和最大值 - from sqlalchemy import text - date_query = text(f"SELECT MIN(日期) as min_date, MAX(日期) as max_date FROM {table_name}") - # V-Fix: SQLAlchemy 2.0 需要使用 connection 對象 - with db.engine.connect() as conn: - result = conn.execute(date_query).fetchone() - if result and result[0] and result[1]: - min_date = result[0] - max_date = result[1] - # 格式化為 YYYY年MM月 格式 - if isinstance(min_date, str): - from datetime import datetime - try: - min_date_obj = datetime.strptime(min_date.split()[0], '%Y-%m-%d') - max_date_obj = datetime.strptime(max_date.split()[0], '%Y-%m-%d') - db_data_range = f"{min_date_obj.year}年{min_date_obj.month}月 ~ {max_date_obj.year}年{max_date_obj.month}月" - except: - db_data_range = f"{min_date} ~ {max_date}" - else: - db_data_range = f"{min_date.year}年{min_date.month}月 ~ {max_date.year}年{max_date.month}月" - except Exception as e: - sys_log.warning(f"[Sales Analysis] 無法取得資料期間範圍: {e}") + # V-Opt: 資料期間在同一批匯入後穩定,避免每次首屏都查 MIN/MAX。 + db_data_range = _fetch_sales_data_range(db.engine, table_name) # V-New: 取得篩選參數 data_range_param = request.args.get('data_range', '') # 不再設預設值 @@ -605,63 +796,13 @@ def sales_analysis(): # V-New: 按需載入 - 如果沒有任何篩選條件,顯示引導頁面 if not data_range_param and not start_date and not end_date: sys_log.info("[Sales Analysis] 👋 首次進入頁面,等待用戶選擇篩選條件") - - # V-Fix: 即使在引導頁面,也要提供下拉選單選項 - # V-Opt: 讀取較多筆數(1000筆)以獲得更完整的月份資訊 - preview_df = pd.read_sql(f"SELECT * FROM {table_name} LIMIT 1000", db.engine) - preview_categories = [] - preview_brands = [] - preview_vendors = [] - preview_activities = [] - preview_payments = [] - preview_months = [] # V-New: 新增月份列表 - - if not preview_df.empty: - cols = preview_df.columns.tolist() - def find_col(keywords): - for k in keywords: - for col in cols: - if k in str(col): return col - return None - - col_category = find_col(['館別', '商品館', '分類', 'Category']) - col_brand = find_col(['品牌', 'Brand']) - col_vendor = find_col(['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier']) - # V-Fix: 優先匹配具體的活動欄位名稱 - col_activity = find_col(['折扣活動名稱', '折價券活動名稱', '滿額再折扣活動名稱', '活動', 'Activity', 'Campaign']) - col_payment = find_col(['付款', 'Payment', 'Pay']) - # V-Fix: 優先匹配「日期」欄位(「訂單日期」是固定文字,不是實際日期) - col_date_part = find_col(['日期', '交易日期', 'Date', 'Day']) - col_time_part = find_col(['訂單時間', '成立時間', '下單時間', '購買時間', '時間', 'Time', 'Created']) - - # V-Fix: 篩選掉空字串,只保留有效數據 - if col_category: - preview_categories = sorted([x for x in preview_df[col_category].dropna().astype(str).unique().tolist() if x and x.strip()]) - if col_brand: - preview_brands = sorted([x for x in preview_df[col_brand].dropna().astype(str).unique().tolist() if x and x.strip()]) - if col_vendor: - preview_vendors = sorted([x for x in preview_df[col_vendor].dropna().astype(str).unique().tolist() if x and x.strip()]) - if col_activity: - preview_activities = sorted([x for x in preview_df[col_activity].dropna().astype(str).unique().tolist() if x and x.strip()]) - if col_payment: - preview_payments = sorted([x for x in preview_df[col_payment].dropna().astype(str).unique().tolist() if x and x.strip()]) - - # V-Fix: 從數據庫直接查詢所有月份(而不是從預覽數據提取,避免只獲得部分月份) - if col_date_part: - try: - from sqlalchemy import text - with db.engine.connect() as conn: - result = conn.execute(text(f""" - SELECT DISTINCT replace(substr("{col_date_part}", 1, 7), '/', '-') as month - FROM {table_name} - WHERE "{col_date_part}" IS NOT NULL AND "{col_date_part}" != '' - ORDER BY month - """)).fetchall() - preview_months = [row[0] for row in result if row[0] and '-' in str(row[0])] - sys_log.info(f"[Sales Analysis] 預覽模式從「{col_date_part}」欄位提取到 {len(preview_months)} 個月份") - except Exception as e: - sys_log.warning(f"[Sales Analysis] 無法從「{col_date_part}」欄位提取月份: {e}") - pass + preview_options = _preview_sales_filter_options(db.engine, table_name) + preview_categories = preview_options['categories'] + preview_brands = preview_options['brands'] + preview_vendors = preview_options['vendors'] + preview_activities = preview_options['activities'] + preview_payments = preview_options['payments'] + preview_months = preview_options['months'] # 傳遞必要的變數以避免模板錯誤 selected_metric = request.args.get('metric', 'amount') @@ -1020,6 +1161,16 @@ def sales_analysis(): } set_sales_processed_cache(cache_key, cache_entry, aliases=(table_name,)) + processed_entry = _SALES_PROCESSED_CACHE.get(cache_key, {}) + page_cache_key = "sales_analysis:page_context:" + _sales_analysis_args_fingerprint( + table_name, + cache_key, + processed_entry.get('time'), + ) + cached_context = _get_sales_page_context_cache(page_cache_key) + if cached_context: + return render_template('sales_analysis.html', **cached_context) + # 🚩 V-Opt: 使用共用篩選函式 target_df, cols_map, err = _get_filtered_sales_data(cache_key) if err: @@ -1043,9 +1194,6 @@ def sales_analysis(): col_date = cols_map.get('date') col_pid = cols_map.get('pid') - # V-Fix: 準備前端需要的下拉選單資料 - # V-Opt: 從數據庫直接查詢所有可用選項,而不是只從篩選後的快取中讀取 - # 這樣可以確保下拉選單顯示完整的可選項,即使當前篩選範圍很小 all_categories = [] all_brands = [] all_vendors = [] @@ -1054,58 +1202,13 @@ def sales_analysis(): all_months = [] try: - from sqlalchemy import text - # V-Fix: SQLAlchemy 2.0 需要使用 connection 對象 - with db.engine.connect() as conn: - # 讀取完整資料表的所有可用選項(使用 DISTINCT 以提升效能) - # V-Fix: 使用單引號空字串,兼容 PostgreSQL - if col_category: - sql = f"SELECT DISTINCT \"{col_category}\" FROM {table_name} WHERE \"{col_category}\" IS NOT NULL AND \"{col_category}\" <> ''" - result = conn.execute(text(sql)).fetchall() - all_categories = sorted([str(row[0]) for row in result if row[0]]) - - if col_brand: - sql = f"SELECT DISTINCT \"{col_brand}\" FROM {table_name} WHERE \"{col_brand}\" IS NOT NULL AND \"{col_brand}\" <> ''" - result = conn.execute(text(sql)).fetchall() - all_brands = sorted([str(row[0]) for row in result if row[0]]) - - if col_vendor: - sql = f"SELECT DISTINCT \"{col_vendor}\" FROM {table_name} WHERE \"{col_vendor}\" IS NOT NULL AND \"{col_vendor}\" <> ''" - result = conn.execute(text(sql)).fetchall() - all_vendors = sorted([str(row[0]) for row in result if row[0]]) - - if col_activity: - sql = f"SELECT DISTINCT \"{col_activity}\" FROM {table_name} WHERE \"{col_activity}\" IS NOT NULL AND \"{col_activity}\" <> ''" - result = conn.execute(text(sql)).fetchall() - all_activities = sorted([str(row[0]) for row in result if row[0]]) - - if col_payment: - sql = f"SELECT DISTINCT \"{col_payment}\" FROM {table_name} WHERE \"{col_payment}\" IS NOT NULL AND \"{col_payment}\" <> ''" - result = conn.execute(text(sql)).fetchall() - all_payments = sorted([str(row[0]) for row in result if row[0]]) - - # V-Fix: 從數據庫提取所有月份(格式:YYYY-MM) - if col_date: - # 從日期欄位提取月份(支援多種日期欄位名稱) - date_fields = ['日期', '訂單日期', '時間'] - for field in date_fields: - try: - # V-Fix: 使用 substr 提取年月部分,並將斜線替換為橫線 - # 數據庫格式: "2025/07/01" -> 提取前7個字符 "2025/07" -> 替換斜線 "2025-07" - result = conn.execute(text(f""" - SELECT DISTINCT replace(substr(\"{field}\", 1, 7), '/', '-') as month - FROM {table_name} - WHERE \"{field}\" IS NOT NULL AND \"{field}\" != '' - ORDER BY month - """)).fetchall() - if result and len(result) > 0: - all_months = [row[0] for row in result if row[0] and '-' in str(row[0])] - if all_months: # 如果成功提取到月份,就使用這個欄位 - sys_log.info(f"[Sales Analysis] 從欄位 {field} 提取到 {len(all_months)} 個月份: {all_months}") - break - except Exception as ex: - sys_log.warning(f"[Sales Analysis] 從欄位 {field} 提取月份失敗: {ex}") - continue + filter_options = _get_sales_filter_options(db.engine, table_name, cols_map) + all_categories = filter_options['categories'] + all_brands = filter_options['brands'] + all_vendors = filter_options['vendors'] + all_activities = filter_options['activities'] + all_payments = filter_options['payments'] + all_months = filter_options['months'] except Exception as e: sys_log.warning(f"[Sales Analysis] 從數據庫查詢下拉選項失敗: {e}") # 如果查詢失敗,回退到從快取讀取 @@ -1531,54 +1634,78 @@ def sales_analysis(): if not target_df.empty: marketing_data = prepare_marketing_summary(target_df, sort_by=selected_metric) - return render_template('sales_analysis.html', - marketing_data=marketing_data, # V-New: 傳遞行銷活動數據 - items=table_items, - kpi={ - 'revenue': total_revenue, - 'qty': total_qty, - 'count': total_count, - 'sku_count': sku_count, # V-Fix 2026-01-15: 唯一商品數 - 'cost': total_cost, - 'gross_margin': gross_margin, - 'gross_margin_rate': gross_margin_rate, - 'avg_price': avg_price - }, - insights=insights, - abc_stats=abc_stats, # V-New: 傳遞 ABC 分析數據 - vendor_stats=vendor_stats, # V-New: 傳遞廠商排行數據 - seasonality_data=seasonality_data, # V-New: 傳遞淡旺季數據 - bar_data=bar_data, - cat_data=cat_data, - category_data=cat_data, - price_dist_data=price_dist_data, - scatter_data=scatter_data, - bcg_data=bcg_data, # V-New: 傳遞 BCG 數據 - dow_data=dow_data, - hourly_data=hourly_data, - monthly_data=monthly_data, - weekly_data=weekly_data, - heatmap_data=heatmap_data, - treemap_data=treemap_data, - all_categories=all_categories, - all_brands=all_brands, all_vendors=all_vendors, all_activities=all_activities, all_payments=all_payments, - all_months=all_months, # V-New: 傳遞月份列表 - selected_category=selected_category, - selected_brand=selected_brand, selected_vendor=selected_vendor, - selected_activity=selected_activity, selected_payment=selected_payment, - selected_dow=selected_dow, selected_hour=selected_hour, - selected_month=selected_month, - selected_metric=selected_metric, - keyword=keyword, min_price=min_price, max_price=max_price, - min_margin=min_margin, max_margin=max_margin, - cols={'name': col_name, 'amount': col_amount, 'qty': col_qty, 'cat': col_category, 'date': col_date, 'cost': col_cost, 'profit': col_profit, 'vendor': col_vendor, 'brand': col_brand, 'return_qty': col_return_qty, 'pid': col_pid}, - table_name=table_name, - data_range_months=data_range_months, - start_date=start_date, # V-New: 傳遞自訂開始日期 - end_date=end_date, # V-New: 傳遞自訂結束日期 - total_records=len(df), - active_page='sales', - db_data_range=db_data_range) # V-New: 傳遞資料庫資料期間 + context = { + 'marketing_data': marketing_data, + 'items': table_items, + 'kpi': { + 'revenue': total_revenue, + 'qty': total_qty, + 'count': total_count, + 'sku_count': sku_count, + 'cost': total_cost, + 'gross_margin': gross_margin, + 'gross_margin_rate': gross_margin_rate, + 'avg_price': avg_price, + }, + 'insights': insights, + 'abc_stats': abc_stats, + 'vendor_stats': vendor_stats, + 'seasonality_data': seasonality_data, + 'bar_data': bar_data, + 'cat_data': cat_data, + 'category_data': cat_data, + 'price_dist_data': price_dist_data, + 'scatter_data': scatter_data, + 'bcg_data': bcg_data, + 'dow_data': dow_data, + 'hourly_data': hourly_data, + 'monthly_data': monthly_data, + 'weekly_data': weekly_data, + 'heatmap_data': heatmap_data, + 'treemap_data': treemap_data, + 'all_categories': all_categories, + 'all_brands': all_brands, + 'all_vendors': all_vendors, + 'all_activities': all_activities, + 'all_payments': all_payments, + 'all_months': all_months, + 'selected_category': selected_category, + 'selected_brand': selected_brand, + 'selected_vendor': selected_vendor, + 'selected_activity': selected_activity, + 'selected_payment': selected_payment, + 'selected_dow': selected_dow, + 'selected_hour': selected_hour, + 'selected_month': selected_month, + 'selected_metric': selected_metric, + 'keyword': keyword, + 'min_price': min_price, + 'max_price': max_price, + 'min_margin': min_margin, + 'max_margin': max_margin, + 'cols': { + 'name': col_name, + 'amount': col_amount, + 'qty': col_qty, + 'cat': col_category, + 'date': col_date, + 'cost': col_cost, + 'profit': col_profit, + 'vendor': col_vendor, + 'brand': col_brand, + 'return_qty': col_return_qty, + 'pid': col_pid, + }, + 'table_name': table_name, + 'data_range_months': data_range_months, + 'start_date': start_date, + 'end_date': end_date, + 'total_records': len(df), + 'active_page': 'sales', + 'db_data_range': db_data_range, + } + _set_sales_page_context_cache(page_cache_key, context) + return render_template('sales_analysis.html', **context) except Exception as e: sys_log.error(f"Sales Analysis Error: {e}") diff --git a/templates/sales_analysis.html b/templates/sales_analysis.html index c7a2c5a..6589ce7 100644 --- a/templates/sales_analysis.html +++ b/templates/sales_analysis.html @@ -847,11 +847,13 @@ {% endblock %} {% block extra_js %} + {% if not no_filter %} + {% endif %}