diff --git a/config.py b/config.py index 83e0371..2205b73 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.172" +SYSTEM_VERSION = "V10.179" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docker-compose.yml b/docker-compose.yml index f82124f..e30a969 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -78,8 +78,8 @@ services: - FLASK_ENV=production - PYTHONUNBUFFERED=1 - TZ=Asia/Taipei - - METABASE_URL=https://mo.wooo.work/metabase - - GRIST_URL=https://grist.wooo.work + - METABASE_URL=/metabase + - GRIST_URL=/grist # 關閉登入驗證(開發/測試用,生產環境預設啟用登入) - DISABLE_LOGIN=${DISABLE_LOGIN:-false} # 資料庫設定: Docker 環境使用 PostgreSQL diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index d728344..a8ddb67 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -1,8 +1,8 @@ # MOMO PRO — AI 競價情報模組 Single Source of Truth -> **最後更新**: 2026-05-13 (台北時間) +> **最後更新**: 2026-05-18 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 僅備援 / 鎖定場景 -> **適用版本**: V10.129 +> **適用版本**: V10.179 --- @@ -105,6 +105,7 @@ SQL漏斗(~300筆) - CD rebuild 模式必須先 build image 成功,再短暫 stop/rm/recreate 三應用容器,避免 no-cache build 造成長時間 502。 - ElephantAlpha 使用 NVIDIA NIM hosted API;production 預設模型為 `nvidia/llama-3.3-nemotron-super-49b-v1.5`,`ELEPHANT_ALPHA_FALLBACK_MODELS` 需保留至少一個可呼叫備援;403/404、408/409/425/429、5xx、timeout 與 connection error 必須嘗試下一個模型。 - OpenClaw/Hermes embedding 優先呼叫 Ollama `/api/embed`,只在舊節點不支援時 fallback `/api/embeddings`;timeout 由 `EMBEDDING_TIMEOUT` / `OLLAMA_EMBED_TIMEOUT` 控制。 +- PPT 自動產線由 `momo-scheduler` 每日 20:30 執行 `run_ppt_auto_generation_task()`,先補齊 `daily` / `weekly` / `monthly` / `strategy` / `competitor` / `promo` 定義簡報,再交給 22:00 `ppt_vision_audit` 做視覺審核;`/observability/ppt_audit_history` 可檢視覆蓋狀態並用 `/observability/ppt_audit/generate_missing` 補齊缺漏,總開關為 `PPT_AUTO_GENERATION_ENABLED`。 --- diff --git a/routes/admin_observability_routes.py b/routes/admin_observability_routes.py index 2f49e56..bb5db5d 100644 --- a/routes/admin_observability_routes.py +++ b/routes/admin_observability_routes.py @@ -1836,6 +1836,26 @@ def ppt_audit_trigger_aider_heal(): return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500 +@admin_observability_bp.route('/ppt_audit/generate_missing', methods=['POST']) +@login_required +def ppt_audit_generate_missing(): + """補齊 PPT audit 頁定義中的簡報產出。 + + 這是非阻塞入口:Web 頁面只負責排入背景 thread,真正的產生流程共用 + Telegram/OpenClaw 既有 generator 與 cache key。 + """ + try: + from services.ppt_auto_generation_service import start_defined_ppt_generation_background + + data = request.get_json(silent=True) or {} + report_types = data.get('report_types') or None + force = bool(data.get('force')) + result = start_defined_ppt_generation_background(report_types=report_types, force=force) + return jsonify(result), 202 if result.get('status') == 'queued' else 200 + except Exception as e: + return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500 + + @admin_observability_bp.route('/ppt_audit_file/') @login_required def ppt_audit_file(filename: str): @@ -2486,6 +2506,32 @@ def ppt_audit_history(): except Exception: pass + auto_generation = { + 'enabled': False, + 'items': [], + 'missing_report_types': [], + 'missing_count': 0, + 'ready_count': 0, + 'total': 0, + 'last_run': None, + 'can_auto_start': False, + } + try: + from services.ppt_auto_generation_service import get_defined_report_coverage + + auto_generation = get_defined_report_coverage( + month_start=month_start, + month_end=month_end, + reports_dir=reports_dir, + ) + auto_generation['can_auto_start'] = ( + bool(auto_generation.get('enabled')) + and int(auto_generation.get('missing_count') or 0) > 0 + and month_label == datetime.now().strftime('%Y-%m') + ) + except Exception: + logger.debug("PPT auto-generation coverage unavailable", exc_info=True) + return render_template( 'admin/ppt_audit_history.html', active_page='obs_ppt_audit', @@ -2502,6 +2548,9 @@ def ppt_audit_history(): audit_30d_stats=audit_30d_stats, top_failure_files=top_failure_files, vision_enabled=vision_enabled, + auto_generation=auto_generation, + auto_generation_items=auto_generation.get('items', []), + auto_generation_missing_report_types=auto_generation.get('missing_report_types', []), error=error, ) diff --git a/routes/system_public_routes.py b/routes/system_public_routes.py index 890e3e7..38f412d 100644 --- a/routes/system_public_routes.py +++ b/routes/system_public_routes.py @@ -45,6 +45,50 @@ def health_check(): }), 500 +@system_public_bp.route('/metabase') +@system_public_bp.route('/metabase/') +@login_required +def metabase_status(): + """Internal status page for the BI entrypoint when the public proxy is not attached.""" + return render_template( + 'external_tool_status.html', + active_page='metabase', + system_version=SYSTEM_VERSION, + tool={ + 'key': 'metabase', + 'eyebrow': 'Analytics Bridge', + 'title': '自訂圖表入口', + 'status_label': '代理尚未接入', + 'summary': '正式入口已留在 momo-pro 內部,避免再落到 404 或空白頁。', + 'detail': 'Metabase 容器以 bi profile 管理;公開路由需由 Gateway / Nginx 接到 momo-metabase:3000 後才會切換為完整 BI 介面。', + 'primary_label': '回月份總表', + 'primary_href': '/monthly_summary_analysis', + }, + ) + + +@system_public_bp.route('/grist') +@system_public_bp.route('/grist/') +@login_required +def grist_status(): + """Internal status page for the data collaboration entrypoint.""" + return render_template( + 'external_tool_status.html', + active_page='grist', + system_version=SYSTEM_VERSION, + tool={ + 'key': 'grist', + 'eyebrow': 'Data Collaboration', + 'title': '資料協作入口', + 'status_label': '錯鏈已攔截', + 'summary': '資料協作不再連到 grist.wooo.work,避免被轉往其他專案站台。', + 'detail': 'Grist 正式域名尚未完成 momo-pro 專案隔離;在完成 Gateway 綁定前,導覽會停在本頁狀態,不再跳出系統邊界。', + 'primary_label': '回月份總表', + 'primary_href': '/monthly_summary_analysis', + }, + ) + + @system_public_bp.route('/metrics') def prometheus_metrics(): """Prometheus 指標端點 - 供 Prometheus 抓取監控資料""" diff --git a/run_scheduler.py b/run_scheduler.py index 85f5d9a..f8b6319 100644 --- a/run_scheduler.py +++ b/run_scheduler.py @@ -8,7 +8,7 @@ run_scheduler.py — momo-scheduler 容器入口點 每 4 小時:competitor_price_feeder、icaim_analysis 每 6 小時:quality_rescore 每 12 小時:dedup_batch - 每 1 天 :db_backup(03:00)、cleanup_agent_context(03:30)、backup_monitor(04:00)、daily_report(09:00)、roi_monthly_report gate(09:05)、ai_smoke_summary(09:10)、observability_daily_summary(09:30)、pchome_match_backfill(10:30)、openclaw_meta_analysis(12:00, Phase 4 降頻)、ppt_vision_audit(22:00)、daily_token_report(23:55) + 每 1 天 :db_backup(03:00)、cleanup_agent_context(03:30)、backup_monitor(04:00)、daily_report(09:00)、roi_monthly_report gate(09:05)、ai_smoke_summary(09:10)、observability_daily_summary(09:30)、pchome_match_backfill(10:30)、openclaw_meta_analysis(12:00, Phase 4 降頻)、ppt_auto_generation(20:30)、ppt_vision_audit(22:00)、daily_token_report(23:55) 每 1 週 :weekly_strategy(週一 06:00) 每 1 月 :monthly_report(每月1日 07:00) """ @@ -39,6 +39,7 @@ from scheduler import ( run_daily_report_task, run_ai_smoke_daily_summary_task, run_monthly_report_task, + run_ppt_auto_generation_task, ) logging.basicConfig( @@ -180,6 +181,10 @@ def _register_schedules(): schedule.every().day.at("09:05").do(run_roi_monthly_report_if_new_month) logger.info("📅 每日 09:05:roi_monthly_report(月初第 1 日才送)") + # PPT 自動簡報補齊(先產出定義中的報表,再交給 22:00 vision audit) + schedule.every().day.at("20:30").do(run_ppt_auto_generation_task) + logger.info("📅 每日 20:30:ppt_auto_generation(補齊 PPT 定義報表)") + # Phase 26: PPT 視覺審核(每日 22:00 掃當天新生 .pptx,有 issues 才推 Telegram) schedule.every().day.at("22:00").do(run_ppt_vision_audit) logger.info("📅 每日 22:00:ppt_vision_audit(PPT_VISION_ENABLED=true 才生效)") diff --git a/scheduler.py b/scheduler.py index 032bd01..661b856 100644 --- a/scheduler.py +++ b/scheduler.py @@ -2783,6 +2783,43 @@ def run_monthly_report_task(): logging.error(f"[Scheduler] [MonthlyReport] auto_heal_service 失敗: {_heal_e}") +def run_ppt_auto_generation_task(): + """每日補齊觀測台定義中的 PPT 簡報。 + + 22:00 的 ppt_vision_audit 只負責視覺審核;這個任務先把 daily / + weekly / monthly / strategy / competitor / promo 產出補齊,讓審核頁不是 + 被動等 Telegram 人工觸發。 + """ + try: + from services.ppt_auto_generation_service import generate_defined_ppt_reports + + result = generate_defined_ppt_reports() + logging.info( + "[Scheduler] [PPTAutoGeneration] status=%s ready=%s errors=%s", + result.get("status"), + result.get("ready", 0), + result.get("errors", 0), + ) + _save_stats("ppt_auto_generation", result) + except Exception as e: + import traceback as _tb + logging.error(f"[Scheduler] [PPTAutoGeneration] 🚨 自動簡報補齊異常: {e}") + _save_stats("ppt_auto_generation", {"status": "Error", "error": str(e)}) + try: + from services.event_router import notify_failure + notify_failure( + task_name="run_ppt_auto_generation_task", + error=e, + source="Scheduler.PPTAutoGeneration", + event_type="ppt_auto_generation_failure", + priority="P2", + title="PPT 自動簡報補齊異常", + trace=_tb.format_exc(), + ) + except Exception as _router_e: + logging.error(f"[Scheduler] [PPTAutoGeneration] event_router 失敗: {_router_e}") + + def run_ai_smoke_daily_summary_task(): """每日 AI 自動化 Smoke trend 摘要推播(只讀 history,不重新執行 smoke)。""" try: diff --git a/services/ppt_auto_generation_service.py b/services/ppt_auto_generation_service.py new file mode 100644 index 0000000..d2b0207 --- /dev/null +++ b/services/ppt_auto_generation_service.py @@ -0,0 +1,326 @@ +""" +PPT auto-generation orchestration. + +The observability page audits generated decks, but the scheduler previously +only ran the vision audit. This service fills that gap by materializing the +defined deck set before the audit window. +""" + +from __future__ import annotations + +import os +import threading +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Iterable, Sequence + +from sqlalchemy import text as sa_text + +from database.manager import get_session + + +TAIPEI_TZ = timezone(timedelta(hours=8)) + +DEFINED_REPORT_TYPES = ("daily", "weekly", "monthly", "strategy", "competitor", "promo") + +REPORT_TYPE_LABELS = { + "daily": "每日日報", + "weekly": "週報", + "monthly": "月報", + "strategy": "策略", + "competitor": "競品", + "promo": "促銷", +} + +REPORT_PREFIXES = { + "daily": "ocbot_daily_", + "weekly": "ocbot_weekly_", + "monthly": "ocbot_monthly_", + "strategy": "ocbot_strategy_", + "competitor": "ocbot_competitor_", + "promo": "ocbot_promo_", +} + +_RUN_LOCK = threading.Lock() +_LAST_RUN: dict | None = None + + +@dataclass(frozen=True) +class PPTAutoJob: + report_type: str + label: str + sub_type: str + sub_arg: str + target_date: str + + +def _truthy(value: str | None, default: bool = False) -> bool: + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def is_ppt_auto_generation_enabled() -> bool: + return _truthy(os.getenv("PPT_AUTO_GENERATION_ENABLED"), default=True) + + +def _parse_report_types(report_types: Iterable[str] | str | None) -> list[str]: + if report_types is None: + raw = os.getenv("PPT_AUTO_REPORT_TYPES", ",".join(DEFINED_REPORT_TYPES)) + parts = raw.split(",") + elif isinstance(report_types, str): + parts = report_types.split(",") + else: + parts = list(report_types) + + parsed = [] + for part in parts: + key = str(part or "").strip().lower() + if key == "all": + return list(DEFINED_REPORT_TYPES) + if key in DEFINED_REPORT_TYPES and key not in parsed: + parsed.append(key) + return parsed or list(DEFINED_REPORT_TYPES) + + +def _latest_sales_date() -> str | None: + try: + from routes.openclaw_bot_routes import latest_date + + return latest_date() + except Exception: + return None + + +def _normalise_date(value: str | None) -> str: + if value: + cleaned = value.strip().replace("-", "/") + try: + dt = datetime.strptime(cleaned, "%Y/%m/%d") + return dt.strftime("%Y/%m/%d") + except ValueError: + pass + return (datetime.now(TAIPEI_TZ) - timedelta(days=1)).strftime("%Y/%m/%d") + + +def build_defined_ppt_jobs( + *, + latest_date: str | None = None, + report_types: Iterable[str] | str | None = None, +) -> list[PPTAutoJob]: + target = _normalise_date(latest_date or _latest_sales_date()) + target_dt = datetime.strptime(target, "%Y/%m/%d") + month_arg = target_dt.strftime("%Y/%m") + promo_start = (target_dt - timedelta(days=6)).strftime("%Y/%m/%d") + promo_arg = f"{promo_start}-{target}" + + job_map = { + "daily": PPTAutoJob("daily", "每日日報", "daily", target, target), + "weekly": PPTAutoJob("weekly", "週報", "weekly", "", target), + "monthly": PPTAutoJob("monthly", "月報", "monthly", month_arg, target), + "strategy": PPTAutoJob("strategy", "策略(月)", "strategy", f"monthly {month_arg}", target), + "competitor": PPTAutoJob("competitor", "競品(月)", "competitor", "monthly", target), + "promo": PPTAutoJob("promo", "促銷(近 7 日)", "promo", promo_arg, target), + } + return [job_map[key] for key in _parse_report_types(report_types)] + + +def get_defined_report_coverage( + *, + month_start: datetime, + month_end: datetime, + reports_dir: str | os.PathLike[str] | None = None, + report_types: Iterable[str] | str | None = None, +) -> dict: + selected_types = _parse_report_types(report_types) + counts = {key: 0 for key in selected_types} + sources = {key: set() for key in selected_types} + + try: + session = get_session() + try: + rows = session.execute( + sa_text( + """ + SELECT report_type, COUNT(*) + FROM ppt_reports + WHERE generated_at >= :month_start + AND generated_at < :month_end + GROUP BY report_type + """ + ), + {"month_start": month_start, "month_end": month_end}, + ).fetchall() + for report_type, count in rows: + if report_type in counts: + counts[report_type] = max(counts[report_type], int(count or 0)) + if count: + sources[report_type].add("database") + finally: + session.close() + except Exception: + pass + + root = Path(reports_dir or os.getenv("REPORTS_DIR", "/app/data/reports")) + if root.is_dir(): + month_start_ts = month_start.timestamp() + month_end_ts = month_end.timestamp() + for path in root.iterdir(): + if not path.is_file() or path.is_symlink() or path.suffix.lower() != ".pptx": + continue + try: + mtime = path.stat().st_mtime + except OSError: + continue + if not (month_start_ts <= mtime < month_end_ts): + continue + for report_type in selected_types: + if path.name.startswith(REPORT_PREFIXES[report_type]): + counts[report_type] += 1 + sources[report_type].add("filesystem") + + items = [ + { + "key": key, + "label": REPORT_TYPE_LABELS[key], + "count": counts[key], + "ready": counts[key] > 0, + "sources": sorted(sources[key]), + } + for key in selected_types + ] + missing = [item for item in items if not item["ready"]] + return { + "enabled": is_ppt_auto_generation_enabled(), + "items": items, + "missing_report_types": [item["key"] for item in missing], + "missing_count": len(missing), + "ready_count": len(items) - len(missing), + "total": len(items), + "last_run": _LAST_RUN, + } + + +def _generate_job(job: PPTAutoJob) -> str | None: + from routes import openclaw_bot_routes as bot_routes + + original_send_message = getattr(bot_routes, "send_message", None) + + def _noop_send_message(*_args, **_kwargs): + return None + + if original_send_message is not None: + bot_routes.send_message = _noop_send_message + try: + return bot_routes._generate_ppt_cmd( + job.sub_type, + job.sub_arg, + 0, + job.target_date, + _reply_to=None, + ) + finally: + if original_send_message is not None: + bot_routes.send_message = original_send_message + + +def generate_defined_ppt_reports( + *, + report_types: Iterable[str] | str | None = None, + force: bool = False, + dry_run: bool = False, + max_jobs: int | None = None, +) -> dict: + global _LAST_RUN + + if not force and not is_ppt_auto_generation_enabled(): + result = { + "ok": False, + "status": "disabled", + "message": "PPT_AUTO_GENERATION_ENABLED=false", + "jobs": [], + } + _LAST_RUN = result + return result + + jobs = build_defined_ppt_jobs(report_types=report_types) + if max_jobs is not None: + jobs = jobs[: max(0, int(max_jobs))] + + if dry_run: + return { + "ok": True, + "status": "planned", + "jobs": [asdict(job) for job in jobs], + } + + if not _RUN_LOCK.acquire(blocking=False): + return { + "ok": True, + "status": "already_running", + "message": "PPT auto-generation is already running.", + "jobs": [], + "last_run": _LAST_RUN, + } + + started_at = datetime.now(TAIPEI_TZ) + results = [] + try: + for job in jobs: + item = asdict(job) + try: + path = _generate_job(job) + item["path"] = path + item["exists"] = bool(path and os.path.exists(path)) + item["status"] = "ready" if item["exists"] else "missing_file" + except Exception as exc: + item["status"] = "error" + item["error"] = f"{type(exc).__name__}: {str(exc)[:220]}" + results.append(item) + + finished_at = datetime.now(TAIPEI_TZ) + result = { + "ok": True, + "status": "completed", + "started_at": started_at.strftime("%Y-%m-%d %H:%M:%S"), + "finished_at": finished_at.strftime("%Y-%m-%d %H:%M:%S"), + "duration_sec": round((finished_at - started_at).total_seconds(), 1), + "jobs": results, + "ready": sum(1 for item in results if item.get("status") == "ready"), + "errors": sum(1 for item in results if item.get("status") == "error"), + } + _LAST_RUN = result + return result + finally: + _RUN_LOCK.release() + + +def start_defined_ppt_generation_background( + *, + report_types: Sequence[str] | str | None = None, + force: bool = False, +) -> dict: + if _RUN_LOCK.locked(): + return { + "ok": True, + "status": "already_running", + "message": "PPT auto-generation is already running.", + "last_run": _LAST_RUN, + } + + def _run(): + generate_defined_ppt_reports(report_types=report_types, force=force) + + thread = threading.Thread(target=_run, name="ppt-auto-generation", daemon=True) + thread.start() + return { + "ok": True, + "status": "queued", + "message": "PPT auto-generation queued.", + "report_types": _parse_report_types(report_types), + } + + +def get_last_generation_status() -> dict | None: + return _LAST_RUN diff --git a/templates/admin/ppt_audit_history.html b/templates/admin/ppt_audit_history.html index 7d5e8bf..b8eb92e 100644 --- a/templates/admin/ppt_audit_history.html +++ b/templates/admin/ppt_audit_history.html @@ -2,159 +2,11 @@ {% block title %}PPT 視覺 QA 產線{% endblock %} -{% block ewooo_content %} - +{% block extra_css %} + +{% endblock %} +{% block ewooo_content %} {% import "admin/_observability_labels.html" as obs_label %} {% set report_is_daily = report_type == 'daily' %} @@ -188,6 +40,13 @@ 視覺問題數 +
+
定義覆蓋
+ + {{ auto_generation.ready_count }}/{{ auto_generation.total }} + + 自動簡報產線 +
{% if error %}
{{ error }}
{% endif %} @@ -214,6 +73,40 @@ {% endfor %} +
+
+
+
自動產生定義
+

簡報產線覆蓋狀態

+
+ +
+
+
+ {% for item in auto_generation_items %} +
+ {{ item.label }} + + {{ '已產生' if item.ready else '待補齊' }} + + {{ item.count }} 筆{% if item.sources %} · {{ item.sources|join(' + ') }}{% endif %} +
+ {% endfor %} +
+
+ {% if auto_generation.enabled %} + 每日 20:30 會自動補齊定義簡報;目前缺漏 {{ auto_generation.missing_count }} 類。 + {% else %} + PPT_AUTO_GENERATION_ENABLED=false,已停用自動補齊。 + {% endif %} +
+
+
@@ -245,7 +138,17 @@ {{ "%.2f"|format(r.confidence) }} {{ r.duration_ms }} {{ (r.error_msg or '')[:80] }} - {% if r.audit_status in ('failed','error') %}{% endif %} + + {% if r.audit_status in ('failed','error') %} + + {% endif %} + {% else %} 目前無 daily 審核歷史;請確認 {{ report_month }} 是否已完成 22:00 排程。 diff --git a/templates/components/_analysis_report_tabs.html b/templates/components/_analysis_report_tabs.html index 1b3cc76..73f7202 100644 --- a/templates/components/_analysis_report_tabs.html +++ b/templates/components/_analysis_report_tabs.html @@ -1,57 +1,5 @@ {# 分析報表第二層分頁:保留頁面內容與圖表邏輯,只提供一致的報表切換入口。 #} {% set _analysis_active = active_page|default('') %} -
{% endmacro %} +{% macro chart_snapshot(labels, values, mode='currency', limit=12) %} + {% set total = labels|length %} +
+ {% for label in labels %} + {% if loop.index > total - limit %} + {% set val = values[loop.index0]|default(0) %} + + {{ label }} + + {% if mode == 'pct' %}{{ "{:+.1f}%".format(val) }} + {% elif mode == 'number' %}{{ "{:,.0f}".format(val) }} + {% else %}${{ "{:,.0f}".format(val) }}{% endif %} + + + {% endif %} + {% endfor %} +
+{% endmacro %} + {% block ewooo_content %}
每日業績趨勢(近 30 天)
-
+
{{ chart_snapshot(chart_data.labels, chart_data.revenue, 'currency') }}
日成長率 (DoD %)
-
+
{{ chart_snapshot(chart_data.labels, chart_data.dod_revenue, 'pct') }}
@@ -278,7 +297,7 @@
週成長對比 (WoW)
-
+
{{ chart_snapshot(chart_data.labels, chart_data.wow_revenue, 'pct') }}
@@ -289,8 +308,9 @@ 左右滑動查看完整圖表
-
+
+ {{ chart_snapshot(chart_data.top10_labels, chart_data.top10_values, 'currency', 10) }}
@@ -314,7 +334,7 @@
折扣活動 Top 10
{% if marketing_data.discount %} -
+
{{ chart_snapshot(marketing_data.discount['labels'], marketing_data.discount['values'], 'currency', 10) }}
{% else %}

暫無折扣活動數據

{% endif %} @@ -322,7 +342,7 @@
折價券活動 Top 10
{% if marketing_data.coupon %} -
+
{{ chart_snapshot(marketing_data.coupon['labels'], marketing_data.coupon['values'], 'currency', 10) }}
{% else %}

暫無折價券活動數據

{% endif %} diff --git a/templates/ewoooc_base.html b/templates/ewoooc_base.html index e084386..eb450bd 100644 --- a/templates/ewoooc_base.html +++ b/templates/ewoooc_base.html @@ -25,6 +25,7 @@ + {% if active_page|default('') in [ 'obs_overview', 'obs_agent_orchestration', 'obs_business_intel', 'obs_host_health', 'obs_ai_calls', 'obs_budget', @@ -49,7 +50,7 @@ {# 群組映射 — Jinja 計算 [data-page-group] #} {% set _page = active_page|default('') %} {% set _group_monitor = ['dashboard', 'edm', 'campaigns'] %} - {% set _group_analytics = ['sales', 'daily_sales', 'monthly', 'growth'] %} + {% set _group_analytics = ['sales', 'daily_sales', 'monthly', 'growth', 'metabase', 'grist'] %} {% set _group_ops = ['vendor_stockout', 'auto_import', 'market_intel'] %} {% set _group_ai = ['ai_recommend', 'ai_history', 'ai_intelligence', 'pchome_crawler', 'price_comparison', 'trends', diff --git a/templates/external_tool_status.html b/templates/external_tool_status.html new file mode 100644 index 0000000..528f563 --- /dev/null +++ b/templates/external_tool_status.html @@ -0,0 +1,33 @@ +{% extends "ewoooc_base.html" %} +{% block title %}{{ tool.title }} - EwoooC{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block ewooo_content %} +
+
+
+
+ + {{ tool.eyebrow }} +
+

{{ tool.title }}

+

{{ tool.summary }}

+
+ {{ tool.status_label }} +
+ +
+
+ 路由狀態 +

入口已由 momo-pro 接管

+

{{ tool.detail }}

+
+ + {{ tool.primary_label }} + +
+
+{% endblock %} diff --git a/templates/growth_analysis.html b/templates/growth_analysis.html index 7137b05..661eaca 100644 --- a/templates/growth_analysis.html +++ b/templates/growth_analysis.html @@ -6,9 +6,23 @@ {% endblock %} +{% macro ga_chart_snapshot(labels, values, mode='currency') %} +
+ {% for label in labels %} + {% set val = values[loop.index0]|default(0) %} + + {{ label }} + + {% if mode == 'pct' %}{{ "{:+.1f}%".format(val) }} + {% else %}${{ "{:,.0f}".format(val) }}{% endif %} + + + {% endfor %} +
+{% endmacro %} + {% block ewooo_content %} -
-
+
{% include 'components/_analysis_report_tabs.html' %} {# ── Page head ──────────────────────────────────── #} @@ -67,8 +81,9 @@
月營收與年增率 (Revenue & YoY)
-
+
+ {{ ga_chart_snapshot(chart_data.labels, chart_data.revenue, 'currency') }}
@@ -76,8 +91,9 @@
月增率分析 (MoM)
-
+
+ {{ ga_chart_snapshot(chart_data.labels, chart_data.mom, 'pct') }}
@@ -88,8 +104,9 @@
平均單價趨勢
-
+
+ {{ ga_chart_snapshot(chart_data.labels, chart_data.aov, 'currency') }}
@@ -97,12 +114,12 @@
獲利能力分析 (Gross Margin %)
-
+
+ {{ ga_chart_snapshot(chart_data.labels, chart_data.margin_rate, 'pct') }}
-
{% endblock %} diff --git a/tests/test_ppt_auto_generation_service.py b/tests/test_ppt_auto_generation_service.py new file mode 100644 index 0000000..bb64a3c --- /dev/null +++ b/tests/test_ppt_auto_generation_service.py @@ -0,0 +1,74 @@ +from datetime import datetime + + +def test_build_defined_ppt_jobs_uses_latest_date(): + from services.ppt_auto_generation_service import build_defined_ppt_jobs + + jobs = build_defined_ppt_jobs(latest_date="2026-05-11") + by_type = {job.report_type: job for job in jobs} + + assert list(by_type) == ["daily", "weekly", "monthly", "strategy", "competitor", "promo"] + assert by_type["daily"].sub_arg == "2026/05/11" + assert by_type["monthly"].sub_arg == "2026/05" + assert by_type["strategy"].sub_arg == "monthly 2026/05" + assert by_type["competitor"].sub_arg == "monthly" + assert by_type["promo"].sub_arg == "2026/05/05-2026/05/11" + + +def test_auto_generation_respects_disabled_flag(monkeypatch): + monkeypatch.setenv("PPT_AUTO_GENERATION_ENABLED", "false") + + from services.ppt_auto_generation_service import generate_defined_ppt_reports + + result = generate_defined_ppt_reports(report_types=["daily"]) + + assert result["ok"] is False + assert result["status"] == "disabled" + + +def test_dry_run_does_not_generate(monkeypatch): + monkeypatch.setenv("PPT_AUTO_GENERATION_ENABLED", "true") + + from services import ppt_auto_generation_service as svc + + monkeypatch.setattr(svc, "_latest_sales_date", lambda: "2026-05-11") + + result = svc.generate_defined_ppt_reports( + report_types=["daily", "monthly"], + dry_run=True, + ) + + assert result["ok"] is True + assert result["status"] == "planned" + assert [job["report_type"] for job in result["jobs"]] == ["daily", "monthly"] + + +def test_coverage_marks_ready_from_database(monkeypatch): + from services import ppt_auto_generation_service as svc + + class _Rows: + def fetchall(self): + return [("daily", 2), ("monthly", 1)] + + class _Session: + def execute(self, *_args, **_kwargs): + return _Rows() + + def close(self): + return None + + monkeypatch.setattr(svc, "get_session", lambda: _Session()) + monkeypatch.setenv("PPT_AUTO_GENERATION_ENABLED", "true") + + result = svc.get_defined_report_coverage( + month_start=datetime(2026, 5, 1), + month_end=datetime(2026, 6, 1), + reports_dir="/tmp/does-not-exist-for-test", + report_types=["daily", "monthly", "weekly"], + ) + + by_key = {item["key"]: item for item in result["items"]} + assert by_key["daily"]["ready"] is True + assert by_key["monthly"]["ready"] is True + assert by_key["weekly"]["ready"] is False + assert result["missing_count"] == 1 diff --git a/web/static/css/analysis-report-tabs.css b/web/static/css/analysis-report-tabs.css new file mode 100644 index 0000000..6eb424e --- /dev/null +++ b/web/static/css/analysis-report-tabs.css @@ -0,0 +1,73 @@ +.analysis-report-tabs { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--momo-space-2, 8px); + margin: 0 0 var(--momo-space-4, 16px); + padding: var(--momo-space-2, 8px); + border: 1px solid var(--momo-border-light, rgba(42, 37, 32, 0.16)); + border-radius: var(--momo-radius-lg, 8px); + background: var(--momo-bg-elevated, #fdfaf2); + box-shadow: var(--momo-shadow-md, 0 0 0 1px rgba(42, 37, 32, 0.10)); +} + +.analysis-report-tabs-spacer { + flex: 1 1 auto; + min-width: var(--momo-space-2, 8px); +} + +.analysis-report-tab { + display: inline-flex; + align-items: center; + gap: var(--momo-space-2, 8px); + min-height: 34px; + padding: 0 var(--momo-space-3, 12px); + border: 1px solid transparent; + border-radius: var(--momo-radius-md, 7px); + color: var(--momo-text-secondary, #645c52); + text-decoration: none; + font-family: var(--momo-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); + font-size: var(--momo-text-body-sm, 13px); + font-weight: var(--momo-font-weight-bold, 700); + letter-spacing: 0; + transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease; +} + +.analysis-report-tab:hover { + border-color: var(--momo-border-light, rgba(42, 37, 32, 0.16)); + background: var(--momo-bg-surface, #faf7f0); + color: var(--momo-text-primary, #2a2520); +} + +.analysis-report-tab.is-active { + border-color: var(--momo-page-accent-dark, #a95846); + background: var(--momo-page-accent, #c89043); + color: var(--momo-page-inverse, #fff8ef); +} + +.analysis-report-tab.is-external { + border-color: var(--momo-border-light, rgba(42, 37, 32, 0.16)); + background: var(--momo-bg-surface, #faf6ec); + font-family: var(--momo-font-family-mono, "SF Mono", Menlo, Consolas, monospace); + font-size: var(--momo-text-caption, 12px); +} + +.analysis-report-tab i { + color: currentColor !important; +} + +@media (max-width: 720px) { + .analysis-report-tabs { + flex-wrap: nowrap; + overflow-x: auto; + scrollbar-width: thin; + } + + .analysis-report-tabs-spacer { + display: none; + } + + .analysis-report-tab { + flex: 0 0 auto; + } +} diff --git a/web/static/css/observability-system.css b/web/static/css/observability-system.css index 0f3dc1d..8428251 100644 --- a/web/static/css/observability-system.css +++ b/web/static/css/observability-system.css @@ -1472,6 +1472,29 @@ background-size: 14px 14px !important; } +.momo-observability-mode .ppt-auto-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: var(--momo-space-2, 8px); +} + +.momo-observability-mode .ppt-auto-status.is-working { + color: var(--obs-accent) !important; + font-weight: var(--momo-font-weight-bold, 700); +} + +@media (max-width: 1200px) { + .momo-observability-mode .ppt-auto-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .momo-observability-mode .ppt-auto-grid { + grid-template-columns: 1fr; + } +} + .momo-observability-mode h1, .momo-observability-mode h2, .momo-observability-mode h3, diff --git a/web/static/css/page-daily-sales.css b/web/static/css/page-daily-sales.css index 9b4e392..f35938e 100644 --- a/web/static/css/page-daily-sales.css +++ b/web/static/css/page-daily-sales.css @@ -875,6 +875,130 @@ height: 320px; } +.chart-container canvas { + display: block; + width: 100% !important; + height: 100% !important; +} + +.chart-container.has-html-chart canvas { + display: none !important; +} + +.chart-fallback-bars { + position: absolute; + inset: 0; + padding: 1rem; + color: var(--momo-text-primary); +} + +.chart-fallback-bars.is-vertical { + display: flex; + align-items: flex-end; + gap: 0.5rem; +} + +.chart-fallback-bars.is-horizontal { + display: grid; + gap: 0.55rem; + align-content: center; +} + +.chart-fallback-bar { + min-width: 0; + color: var(--momo-text-secondary); +} + +.chart-fallback-bars.is-vertical .chart-fallback-bar { + position: relative; + flex: 1 1 0; + height: var(--bar-h); + border-radius: 6px 6px 2px 2px; + background: color-mix(in srgb, var(--momo-page-accent) 72%, white); + border: 1px solid var(--momo-page-accent-line); + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: 0.35rem 0.25rem; +} + +.chart-fallback-bars.is-horizontal .chart-fallback-bar { + display: grid; + grid-template-columns: minmax(88px, 0.42fr) minmax(0, 1fr) auto; + align-items: center; + gap: 0.5rem; +} + +.chart-fallback-bars.is-horizontal .chart-fallback-bar::before { + content: ""; + height: 16px; + width: var(--bar-w); + min-width: 4px; + border-radius: 4px; + background: color-mix(in srgb, var(--momo-page-accent) 72%, white); + border: 1px solid var(--momo-page-accent-line); +} + +.chart-fallback-bar.is-negative { + background: color-mix(in srgb, var(--momo-danger-bg) 84%, white); +} + +.chart-fallback-bar.is-negative::before { + background: color-mix(in srgb, var(--momo-danger-bg) 84%, white); +} + +.chart-fallback-label, +.chart-fallback-value { + font-family: var(--momo-font-mono); + font-size: var(--momo-text-caption, 12px); + letter-spacing: 0; +} + +.chart-fallback-value { + color: var(--momo-text-primary); +} + +.chart-fallback-list { + position: absolute; + inset: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--momo-space-2, 8px); + align-content: center; + padding: var(--momo-space-3, 12px); +} + +.chart-fallback-item { + min-width: 0; + border: 1px solid var(--momo-page-accent-line); + border-radius: var(--momo-radius-md, 6px); + background: color-mix(in srgb, var(--momo-page-accent-soft) 72%, white); + padding: var(--momo-space-2, 8px); + display: flex; + justify-content: space-between; + gap: var(--momo-space-2, 8px); +} + +.chart-fallback-item b, +.chart-fallback-item strong { + min-width: 0; + font-family: var(--momo-font-mono); + font-size: var(--momo-text-caption, 12px); + letter-spacing: 0; +} + +.chart-fallback-item b { + color: var(--momo-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chart-fallback-item strong { + color: var(--momo-text-primary); + white-space: nowrap; +} + .chart-container--md { height: 350px; } .chart-responsive { diff --git a/web/static/css/page-external-tools.css b/web/static/css/page-external-tools.css new file mode 100644 index 0000000..eb864d2 --- /dev/null +++ b/web/static/css/page-external-tools.css @@ -0,0 +1,98 @@ +.external-tool-page { + display: flex; + flex-direction: column; + gap: var(--momo-space-4, 16px); +} + +.external-tool-hero, +.external-tool-panel { + background: + radial-gradient(circle, rgba(45, 40, 32, 0.12) 1px, transparent 1.2px), + var(--momo-bg-surface); + background-size: 12px 12px, auto; + border: 1px solid var(--momo-border-strong); + border-radius: var(--momo-radius-lg, 8px); + box-shadow: var(--momo-shadow-sm); +} + +.external-tool-hero { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--momo-space-5, 24px); + padding: var(--momo-space-6, 32px); +} + +.external-tool-eyebrow, +.external-tool-kicker { + display: inline-flex; + align-items: center; + gap: var(--momo-space-2, 8px); + color: var(--momo-accent-rust); + font-size: var(--momo-text-body-sm); + font-weight: var(--momo-font-weight-bold); + letter-spacing: 0; + text-transform: uppercase; +} + +.external-tool-hero h1, +.external-tool-panel h2 { + margin: var(--momo-space-3, 12px) 0 var(--momo-space-2, 8px); + color: var(--momo-text-primary); + font-weight: var(--momo-font-weight-black); + letter-spacing: 0; +} + +.external-tool-hero h1 { + font-size: 48px; +} + +.external-tool-panel h2 { + font-size: var(--momo-text-title-lg); +} + +.external-tool-hero p, +.external-tool-panel p { + max-width: 760px; + margin: 0; + color: var(--momo-text-secondary); + font-size: var(--momo-text-body-lg); + line-height: 1.8; +} + +.external-tool-status { + flex: 0 0 auto; + border: 1px solid var(--momo-accent-rust); + border-radius: var(--momo-radius-pill, 999px); + color: var(--momo-accent-rust); + font-weight: var(--momo-font-weight-bold); + padding: var(--momo-space-2, 8px) var(--momo-space-3, 12px); +} + +.external-tool-panel { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--momo-space-5, 24px); + padding: var(--momo-space-5, 24px); +} + +.external-tool-panel .btn { + display: inline-flex; + align-items: center; + gap: var(--momo-space-2, 8px); + white-space: nowrap; +} + +@media (max-width: 720px) { + .external-tool-hero, + .external-tool-panel { + flex-direction: column; + align-items: stretch; + padding: var(--momo-space-4, 16px); + } + + .external-tool-hero h1 { + font-size: 32px; + } +} diff --git a/web/static/css/page-growth-bem.css b/web/static/css/page-growth-bem.css index eb33dad..94775a2 100644 --- a/web/static/css/page-growth-bem.css +++ b/web/static/css/page-growth-bem.css @@ -226,10 +226,85 @@ padding: var(--momo-space-4, 16px); height: var(--ga-chart-h, 320px); } +.growth-analysis-page .ga-chart-card__body--lg { + height: 350px; +} +.growth-analysis-page .ga-chart-card__body--md { + height: 300px; +} .growth-analysis-page .ga-chart-card__body canvas { width: 100% !important; height: 100% !important; } +.growth-analysis-page .ga-chart-card__body.has-html-chart canvas { + display: none !important; +} +.growth-analysis-page .ga-chart-fallback { + position: absolute; + inset: var(--momo-space-4, 16px); + display: flex; + align-items: flex-end; + gap: var(--momo-space-2, 8px); +} +.growth-analysis-page .ga-chart-card__body { + position: relative; +} +.growth-analysis-page .ga-chart-fallback__bar { + flex: 1 1 0; + height: var(--bar-h); + min-height: 8px; + border: 1px solid var(--momo-page-accent-line); + border-radius: 6px 6px 2px 2px; + background: color-mix(in srgb, var(--momo-page-accent) 72%, white); + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: var(--momo-space-1, 4px); +} +.growth-analysis-page .ga-chart-fallback__bar.is-negative { + background: color-mix(in srgb, var(--momo-danger-bg) 84%, white); +} +.growth-analysis-page .ga-chart-fallback__bar span, +.growth-analysis-page .ga-chart-fallback__bar strong { + font-family: var(--momo-font-mono); + font-size: var(--momo-text-caption, 12px); + letter-spacing: 0; +} +.growth-analysis-page .ga-chart-snapshot { + position: absolute; + inset: var(--momo-space-4, 16px); + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--momo-space-2, 8px); + align-content: center; +} +.growth-analysis-page .ga-chart-snapshot__item { + min-width: 0; + border: 1px solid var(--momo-page-accent-line); + border-radius: var(--momo-radius-md, 6px); + background: color-mix(in srgb, var(--momo-page-accent-soft) 72%, white); + padding: var(--momo-space-2, 8px); + display: flex; + justify-content: space-between; + gap: var(--momo-space-2, 8px); +} +.growth-analysis-page .ga-chart-snapshot__item b, +.growth-analysis-page .ga-chart-snapshot__item strong { + min-width: 0; + font-family: var(--momo-font-mono); + font-size: var(--momo-text-caption, 12px); + letter-spacing: 0; +} +.growth-analysis-page .ga-chart-snapshot__item b { + color: var(--momo-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.growth-analysis-page .ga-chart-snapshot__item strong { + color: var(--momo-text-primary); + white-space: nowrap; +} @media (max-width: 640px) { .growth-analysis-page .ga-page-head { diff --git a/web/static/css/page-ppt-audit-history.css b/web/static/css/page-ppt-audit-history.css new file mode 100644 index 0000000..e78e0ee --- /dev/null +++ b/web/static/css/page-ppt-audit-history.css @@ -0,0 +1,204 @@ +.ppt-hero, +.ppt-panel, +.ppt-table-shell { + border: 1px solid var(--obs-line); + border-radius: var(--momo-radius-lg, 8px); + background: var(--obs-card); + box-shadow: var(--momo-shadow-md, 0 16px 38px rgba(70, 46, 28, 0.08)); +} + +.ppt-hero { + padding: var(--momo-space-5, 24px); + background: + radial-gradient(circle, rgba(45, 40, 32, 0.12) 1px, transparent 1.2px), + linear-gradient(135deg, rgba(255, 248, 239, 0.98), rgba(255, 255, 255, 0.78)); + background-size: 12px 12px, auto; +} + +.ppt-kicker { + color: var(--obs-accent); + font-size: var(--momo-text-caption, 12px); + letter-spacing: 0; + font-weight: var(--momo-font-weight-bold, 700); +} + +.ppt-title { + margin: var(--momo-space-2, 8px) 0 var(--momo-space-1, 4px); + font-family: var(--momo-font-display, "Inter", "Noto Sans TC", system-ui, sans-serif); + font-size: var(--obs-title-size); + letter-spacing: 0; + line-height: var(--momo-line-height-tight, 1.08); +} + +.ppt-subtitle { + color: var(--obs-muted); + max-width: 860px; + line-height: 1.7; +} + +.ppt-command { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--momo-space-3, 12px); + margin-top: var(--momo-space-4, 16px); +} + +.ppt-signal { + padding: var(--momo-space-3, 12px); + border: 1px solid var(--obs-line); + border-radius: var(--momo-radius-lg, 8px); + background: rgba(255, 255, 255, 0.62); +} + +.ppt-label { + color: var(--obs-muted); + font-size: var(--momo-text-caption, 12px); + letter-spacing: 0; + font-weight: var(--momo-font-weight-bold, 700); +} + +.ppt-value { + display: block; + margin-top: var(--momo-space-1, 4px); + font-size: var(--obs-value-size); + font-weight: var(--momo-font-weight-black, 800); + letter-spacing: 0; +} + +.ppt-toolbar { + margin-top: var(--momo-space-4, 16px); + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--momo-space-3, 12px); + flex-wrap: wrap; +} + +.ppt-type-tabs { + display: flex; + flex-wrap: wrap; + gap: var(--momo-space-2, 8px); +} + +.ppt-type-chip { + display: inline-flex; + align-items: center; + gap: var(--momo-space-1, 4px); +} + +.ppt-grid { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(330px, 0.8fr); + gap: var(--momo-space-4, 16px); + margin-top: var(--momo-space-4, 16px); +} + +.ppt-stack { + display: grid; + gap: var(--momo-space-4, 16px); +} + +.ppt-panel-head, +.ppt-table-title { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--momo-space-4, 16px); + padding: var(--momo-space-4, 16px) var(--momo-space-4, 16px) 0; +} + +.ppt-panel-title, +.ppt-table-title h3 { + margin: var(--momo-space-1, 4px) 0 0; + font-size: var(--momo-text-title, 18px); + font-weight: var(--momo-font-weight-black, 800); + letter-spacing: 0; +} + +.ppt-panel-body { + padding: var(--momo-space-4, 16px); +} + +.ppt-table-shell { + overflow: hidden; + margin-top: var(--momo-space-4, 16px); +} + +.ppt-table-shell .table { + min-width: 760px; +} + +.ppt-mini-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--momo-space-3, 12px); +} + +.ppt-mini, +.fix-card { + padding: var(--momo-space-3, 12px); + border: 1px solid var(--obs-line); + border-radius: var(--momo-radius-lg, 8px); + background: rgba(255, 255, 255, 0.58); +} + +.fix-card { + margin-bottom: var(--momo-space-3, 12px); +} + +.ppt-mini strong { + display: block; + margin-top: var(--momo-space-1, 4px); + font-size: var(--momo-text-headline, 22px); + letter-spacing: 0; +} + +.status-good { + color: var(--obs-green); +} + +.status-warn { + color: var(--obs-amber); +} + +.status-bad { + color: var(--obs-red); +} + +.status-blue { + color: var(--obs-blue); +} + +.ppt-file-actions { + display: flex; + gap: var(--momo-space-2, 8px); + flex-wrap: wrap; +} + +.ppt-file-actions .btn { + display: inline-flex; + align-items: center; + gap: var(--momo-space-1, 4px); +} + +@media (max-width: 1180px) { + .ppt-command { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .ppt-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .ppt-command, + .ppt-mini-grid { + grid-template-columns: 1fr; + } + + .ppt-panel-head, + .ppt-table-title { + flex-direction: column; + } +} diff --git a/web/static/js/observability-charts.js b/web/static/js/observability-charts.js index f4ea120..fa48a00 100644 --- a/web/static/js/observability-charts.js +++ b/web/static/js/observability-charts.js @@ -593,6 +593,83 @@ } }; + function initPptAutoGeneration() { + const panel = document.querySelector('[data-ppt-auto-generation]'); + document.querySelectorAll('[data-ppt-aider-heal]').forEach(button => { + if (button.dataset.bound === '1') return; + button.dataset.bound = '1'; + button.addEventListener('click', () => { + window.triggerAiderHeal(button.dataset.pptFilename || '', button.dataset.pptError || ''); + }); + }); + + if (!panel) return; + + const button = panel.querySelector('[data-ppt-generate-missing]'); + const status = panel.querySelector('[data-ppt-auto-status]'); + const reportTypes = (panel.dataset.reportTypes || '') + .split(',') + .map(value => value.trim()) + .filter(Boolean); + + async function triggerGeneration(isAuto) { + if (button) { + button.disabled = true; + button.innerHTML = '補齊中'; + } + if (status) { + status.classList.add('is-working'); + status.textContent = isAuto + ? '偵測到本月定義簡報缺漏,已排入背景補齊。' + : '已排入背景補齊,產出完成後重新整理即可看到最新檔案。'; + } + + try { + const response = await postJson('/observability/ppt_audit/generate_missing', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ report_types: reportTypes }) + }); + const data = await response.json(); + if (status) { + status.textContent = data.message || (data.status === 'queued' + ? '已排入背景補齊,請稍後重新整理。' + : `狀態:${data.status || '已送出'}`); + } + } catch (error) { + console.warn('ppt_auto_generation_failed', error); + if (status) { + status.classList.remove('is-working'); + status.textContent = '補齊任務送出失敗,請稍後再試或查看系統日誌。'; + } + if (button) button.disabled = false; + } + } + + if (button) { + button.addEventListener('click', () => triggerGeneration(false)); + } + + if (panel.dataset.autoStart === 'true') { + const key = `ppt-auto-generation:${new Date().toISOString().slice(0, 10)}`; + let last = 0; + const now = Date.now(); + try { + last = Number(window.localStorage.getItem(key) || 0); + } catch (_error) { + last = 0; + } + if (reportTypes.length && (!last || now - last > 6 * 60 * 60 * 1000)) { + try { + window.localStorage.setItem(key, String(now)); + } catch (_error) { + // Ignore storage failures; the server-side generation lock still protects the job. + } + triggerGeneration(true); + } + } + } + function escapeHtml(value) { if (!value) return ''; return String(value).replace(/[&<>"']/g, char => ({ @@ -661,6 +738,7 @@ renderPromotionReview(); renderQualityTrend(); renderPptAudit(); + initPptAutoGeneration(); } if (document.readyState === 'loading') { diff --git a/web/static/js/page-daily-sales.js b/web/static/js/page-daily-sales.js index e720038..6fa663f 100644 --- a/web/static/js/page-daily-sales.js +++ b/web/static/js/page-daily-sales.js @@ -82,6 +82,88 @@ top10_labels: cd.top10_labels || [], top10_values: cd.top10_values || [] }; + const chartInstances = []; + + function rememberChart(chart) { + if (chart) chartInstances.push(chart); + return chart; + } + + function stabilizeCharts() { + window.requestAnimationFrame(() => { + chartInstances.forEach(chart => { + if (!chart) return; + if (typeof chart.resize === 'function') chart.resize(); + if (typeof chart.update === 'function') chart.update('none'); + }); + window.dispatchEvent(new Event('resize')); + }); + } + + function formatShort(value, mode) { + const n = Number(value || 0); + if (mode === 'pct') return `${n.toFixed(1)}%`; + if (mode === 'currency') return `$${Math.round(n).toLocaleString()}`; + return Math.round(n).toLocaleString(); + } + + function renderHtmlBars(canvasId, labels, values, options = {}) { + const canvas = document.getElementById(canvasId); + const wrap = canvas ? canvas.closest('.chart-container') : null; + if (!wrap || wrap.querySelector('.chart-fallback-bars')) return; + const pairs = (labels || []).map((label, index) => ({ + label: String(label || ''), + value: Number((values || [])[index] || 0) + })).filter(item => Number.isFinite(item.value)); + const data = options.limit ? pairs.slice(-options.limit) : pairs; + if (!data.length) return; + + wrap.classList.add('has-html-chart'); + canvas.setAttribute('aria-hidden', 'true'); + const max = Math.max(...data.map(item => Math.abs(item.value)), 1); + const chart = document.createElement('div'); + chart.className = `chart-fallback-bars ${options.horizontal ? 'is-horizontal' : 'is-vertical'}`; + + data.forEach(item => { + const bar = document.createElement('div'); + bar.className = `chart-fallback-bar ${item.value < 0 ? 'is-negative' : ''}`; + const pct = Math.max(4, Math.round(Math.abs(item.value) / max * 100)); + if (options.horizontal) { + bar.style.setProperty('--bar-w', `${pct}%`); + } else { + bar.style.setProperty('--bar-h', `${pct}%`); + } + + const label = document.createElement('span'); + label.className = 'chart-fallback-label'; + label.textContent = item.label.length > 10 ? `${item.label.slice(0, 10)}...` : item.label; + const value = document.createElement('strong'); + value.className = 'chart-fallback-value'; + value.textContent = formatShort(item.value, options.mode); + bar.append(label, value); + chart.appendChild(bar); + }); + wrap.appendChild(chart); + } + + function renderHtmlChartFallbacks() { + renderHtmlBars('trendChart', safe.labels, safe.revenue, { mode: 'currency', limit: 14 }); + renderHtmlBars('dodChart', safe.labels, safe.dod_revenue, { mode: 'pct', limit: 14 }); + renderHtmlBars('wowChart', safe.labels, safe.wow_revenue, { mode: 'pct', limit: 14 }); + renderHtmlBars('top10Chart', safe.top10_labels, safe.top10_values, { + mode: 'currency', + horizontal: true + }); + const mk = dailySalesData.marketing || {}; + if (mk.discount) renderHtmlBars('discountChart', mk.discount.labels, mk.discount.values, { + mode: 'currency', + horizontal: true + }); + if (mk.coupon) renderHtmlBars('couponChart', mk.coupon.labels, mk.coupon.values, { + mode: 'currency', + horizontal: true + }); + } // -- Helpers ---------------------------------------------------------- function makeLineDataset(label, data, color, yAxisID) { @@ -113,7 +195,7 @@ const el = document.getElementById('trendChart'); if (!el || !safe.labels.length) return; - new Chart(el, { + rememberChart(new Chart(el, { type: 'line', data: { labels: safe.labels, @@ -145,7 +227,7 @@ } } } - }); + })); } // -- Chart 2: DoD (multi-line %) -------------------------------------- @@ -153,7 +235,7 @@ const el = document.getElementById('dodChart'); if (!el || !safe.labels.length) return; - new Chart(el, { + rememberChart(new Chart(el, { type: 'line', data: { labels: safe.labels, @@ -180,7 +262,7 @@ y: { beginAtZero: false, title: { display: true, text: 'DoD 成長率 (%)' } } } } - }); + })); } // -- Chart 3: WoW (multi-line %, 前 7 天淡灰) ------------------------- @@ -188,7 +270,7 @@ const el = document.getElementById('wowChart'); if (!el || !safe.labels.length) return; - new Chart(el, { + rememberChart(new Chart(el, { type: 'line', data: { labels: safe.labels, @@ -222,7 +304,7 @@ y: { beginAtZero: false, title: { display: true, text: 'WoW 成長率 (%)' } } } } - }); + })); } // -- Chart 4: Top 10 (橫向 bar) --------------------------------------- @@ -230,7 +312,7 @@ const el = document.getElementById('top10Chart'); if (!el || !safe.top10_labels.length) return; - new Chart(el, { + rememberChart(new Chart(el, { type: 'bar', data: { labels: safe.top10_labels, @@ -249,7 +331,7 @@ plugins: { legend: { display: false } }, scales: { x: { beginAtZero: true } } } - }); + })); } // -- Marketing charts ---------------------------------------------- @@ -261,7 +343,7 @@ return rgba(color, Math.max(a, 0.25)); }); - new Chart(el.getContext('2d'), { + rememberChart(new Chart(el.getContext('2d'), { type: 'bar', data: { labels: marketing.labels, @@ -301,7 +383,7 @@ }, onClick: () => exportMarketingData() } - }); + })); } // -- DataTables init ------------------------------------------------ @@ -430,12 +512,26 @@ const mk = dailySalesData.marketing || {}; if (mk.discount) renderMarketingBar('discountChart', mk.discount, palette.caramel); if (mk.coupon) renderMarketingBar('couponChart', mk.coupon, palette.olive); + stabilizeCharts(); + renderHtmlChartFallbacks(); } function bootCharts() { + document.documentElement.dataset.dailyCharts = 'loading'; loadChartJs() - .then(renderAllCharts) - .catch(error => console.error('[daily_sales] Chart.js 載入失敗:', error)); + .then(() => { + renderAllCharts(); + document.documentElement.dataset.dailyCharts = 'ready'; + }) + .catch(error => { + document.documentElement.dataset.dailyCharts = 'error'; + document.documentElement.dataset.dailyChartsError = error && error.message ? error.message : String(error); + console.error('[daily_sales] Chart.js 載入失敗:', error); + }); + } + + function scheduleChartBoot() { + window.setTimeout(bootCharts, 0); } function observeCharts() { @@ -458,6 +554,6 @@ document.addEventListener('DOMContentLoaded', function () { initDailySalesActions(); initDataTable(); - observeCharts(); + scheduleChartBoot(); }); })(); diff --git a/web/static/js/page-growth.js b/web/static/js/page-growth.js index 3a02ef2..98e8959 100644 --- a/web/static/js/page-growth.js +++ b/web/static/js/page-growth.js @@ -33,6 +33,66 @@ rust: token('--momo-danger-text', '#7a3210'), rustSoft: token('--momo-danger-bg', '#efd3c4') }; + const chartInstances = []; + + function rememberChart(chart) { + if (chart) chartInstances.push(chart); + return chart; + } + + function stabilizeCharts() { + window.requestAnimationFrame(() => { + chartInstances.forEach(chart => { + if (!chart) return; + if (typeof chart.resize === 'function') chart.resize(); + if (typeof chart.update === 'function') chart.update('none'); + }); + window.dispatchEvent(new Event('resize')); + }); + } + + function formatShort(value, mode) { + const n = Number(value || 0); + if (mode === 'pct') return `${n.toFixed(1)}%`; + if (mode === 'currency') return `$${Math.round(n).toLocaleString()}`; + return Math.round(n).toLocaleString(); + } + + function renderHtmlBars(canvasId, labels, values, options = {}) { + const canvas = document.getElementById(canvasId); + const wrap = canvas ? canvas.closest('.ga-chart-card__body') : null; + if (!wrap || wrap.querySelector('.ga-chart-fallback')) return; + const pairs = (labels || []).map((label, index) => ({ + label: String(label || ''), + value: Number((values || [])[index] || 0) + })).filter(item => Number.isFinite(item.value)); + if (!pairs.length) return; + const max = Math.max(...pairs.map(item => Math.abs(item.value)), 1); + wrap.classList.add('has-html-chart'); + canvas.setAttribute('aria-hidden', 'true'); + + const chart = document.createElement('div'); + chart.className = 'ga-chart-fallback'; + pairs.forEach(item => { + const bar = document.createElement('div'); + bar.className = `ga-chart-fallback__bar ${item.value < 0 ? 'is-negative' : ''}`; + bar.style.setProperty('--bar-h', `${Math.max(4, Math.round(Math.abs(item.value) / max * 100))}%`); + const label = document.createElement('span'); + label.textContent = item.label; + const value = document.createElement('strong'); + value.textContent = formatShort(item.value, options.mode); + bar.append(label, value); + chart.appendChild(bar); + }); + wrap.appendChild(chart); + } + + function renderHtmlChartFallbacks() { + renderHtmlBars('revenueChart', data.labels, data.revenue, { mode: 'currency' }); + renderHtmlBars('momChart', data.labels, data.mom, { mode: 'pct' }); + renderHtmlBars('aovChart', data.labels, data.aov, { mode: 'currency' }); + renderHtmlBars('marginChart', data.labels, data.margin_rate, { mode: 'pct' }); + } function loadChartJs() { if (window.EwoooCChartTheme && window.EwoooCChartTheme.loadChartJs) { @@ -56,7 +116,7 @@ const marginEl = document.getElementById('marginChart'); if (!revenueEl || !momEl || !aovEl || !marginEl) return; - new Chart(revenueEl, { + rememberChart(new Chart(revenueEl, { type: 'bar', data: { labels: data.labels, @@ -91,9 +151,9 @@ } } } - }); + })); - new Chart(momEl, { + rememberChart(new Chart(momEl, { type: 'bar', data: { labels: data.labels, @@ -108,9 +168,9 @@ maintainAspectRatio: false, plugins: { legend: { display: false } } } - }); + })); - new Chart(aovEl, { + rememberChart(new Chart(aovEl, { type: 'line', data: { labels: data.labels, @@ -128,9 +188,9 @@ maintainAspectRatio: false, scales: { y: { beginAtZero: true } } } - }); + })); - new Chart(marginEl, { + rememberChart(new Chart(marginEl, { type: 'line', data: { labels: data.labels, @@ -148,13 +208,27 @@ maintainAspectRatio: false, scales: { y: { beginAtZero: true } } } - }); + })); + stabilizeCharts(); + renderHtmlChartFallbacks(); } function bootCharts() { + document.documentElement.dataset.growthCharts = 'loading'; loadChartJs() - .then(renderCharts) - .catch(error => console.error('[growth_analysis] Chart.js 載入失敗:', error)); + .then(() => { + renderCharts(); + document.documentElement.dataset.growthCharts = 'ready'; + }) + .catch(error => { + document.documentElement.dataset.growthCharts = 'error'; + document.documentElement.dataset.growthChartsError = error && error.message ? error.message : String(error); + console.error('[growth_analysis] Chart.js 載入失敗:', error); + }); + } + + function scheduleChartBoot() { + window.setTimeout(bootCharts, 0); } function observeCharts() { @@ -175,8 +249,8 @@ } if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', observeCharts, { once: true }); + document.addEventListener('DOMContentLoaded', scheduleChartBoot, { once: true }); } else { - observeCharts(); + scheduleChartBoot(); } })();