From cb02cd350f40038e97903ed65097368758251f97 Mon Sep 17 00:00:00 2001 From: OoO Date: Mon, 18 May 2026 14:22:09 +0800 Subject: [PATCH] feat: schedule full ppt auto generation cadence --- database/manager.py | 1 + database/ppt_generation_runs.py | 32 ++ docs/AI_INTELLIGENCE_MODULE_SOT.md | 2 +- migrations/038_create_ppt_generation_runs.sql | 26 + routes/admin_observability_routes.py | 28 +- routes/openclaw_bot_routes.py | 50 +- run_scheduler.py | 48 +- scheduler.py | 23 +- services/ppt_auto_generation_service.py | 457 ++++++++++++++++-- templates/admin/ppt_audit_history.html | 9 +- tests/test_ppt_auto_generation_service.py | 76 ++- web/static/css/page-ppt-audit-history.css | 11 + 12 files changed, 689 insertions(+), 74 deletions(-) create mode 100644 database/ppt_generation_runs.py create mode 100644 migrations/038_create_ppt_generation_runs.sql diff --git a/database/manager.py b/database/manager.py index 976f6f5..51dce5b 100644 --- a/database/manager.py +++ b/database/manager.py @@ -35,6 +35,7 @@ from .autoheal_models import ( # noqa: F401 - ADR-013 AIOps 自動修復表 from .import_models import ImportJob, ImportConfig # noqa: F401 - 確保 import_jobs/import_config 被 Base.metadata 管理 from .notification_models import NotificationTemplate # noqa: F401 - 確保 notification_templates 表被 Base.metadata 管理 from .ppt_reports import PPTReport # noqa: F401 - 確保 ppt_reports 表被 Base.metadata 管理 +from .ppt_generation_runs import PPTGenerationRun # noqa: F401 - 確保 ppt_generation_runs 表被 Base.metadata 管理 from .vendor_models import VendorStockout, VendorList, VendorEmail, EmailSendLog # noqa: F401 - 確保 vendor 表被 Base.metadata 管理 from .realtime_sales_models import RealtimeSalesMonthly # noqa: F401 - 確保 realtime_sales_monthly 被 Base.metadata 管理 from .market_intel_models import ( # noqa: F401 - ADR-035 market_* 表 diff --git a/database/ppt_generation_runs.py b/database/ppt_generation_runs.py new file mode 100644 index 0000000..1b35eda --- /dev/null +++ b/database/ppt_generation_runs.py @@ -0,0 +1,32 @@ +"""PPT 定期產出執行紀錄模型。""" + +from datetime import datetime + +from sqlalchemy import Column, DateTime, Integer, String, Text + +from database.models import Base + + +class PPTGenerationRun(Base): + """每次日/週/月/季/半年/年度 PPT 產出嘗試。""" + + __tablename__ = "ppt_generation_runs" + + id = Column(Integer, primary_key=True) + schedule_kind = Column(String(40), nullable=False, index=True) + report_type = Column(String(50), nullable=False, index=True) + target_label = Column(String(160)) + status = Column(String(30), nullable=False) + parameters_json = Column(Text) + file_path = Column(String(500)) + file_size = Column(Integer) + error_msg = Column(Text) + result_payload = Column(Text) + started_at = Column(DateTime, default=datetime.now, index=True) + finished_at = Column(DateTime) + + def __repr__(self): + return ( + f"" + ) diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index f78abc3..19557b3 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -106,7 +106,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`。 +- PPT 自動產線由 `momo-scheduler` 依節奏執行 `run_ppt_auto_generation_task(schedule_kind)`:每日 20:30 產日報、週一 20:40 產週報/市場情報、每月 1 日 20:50 產月報與管理型簡報、季初 21:00 產季報、半年初 21:10 產半年報、年初 21:20 產年報,再交給 22:00 `ppt_vision_audit` 做視覺審核;每次嘗試會寫入 `ppt_generation_runs`,`/observability/ppt_audit_history` 以精準參數檢查目標版本是否已產生,並可用 `/observability/ppt_audit/generate_missing` 手動補齊缺漏,總開關為 `PPT_AUTO_GENERATION_ENABLED`。 --- diff --git a/migrations/038_create_ppt_generation_runs.sql b/migrations/038_create_ppt_generation_runs.sql new file mode 100644 index 0000000..f0bf283 --- /dev/null +++ b/migrations/038_create_ppt_generation_runs.sql @@ -0,0 +1,26 @@ +-- 038_create_ppt_generation_runs.sql +-- PPT 定期產出嘗試紀錄:保留每次日/週/月/季/半年/年排程的狀態與結果。 + +CREATE TABLE IF NOT EXISTS ppt_generation_runs ( + id SERIAL PRIMARY KEY, + schedule_kind VARCHAR(40) NOT NULL, + report_type VARCHAR(50) NOT NULL, + target_label VARCHAR(160), + status VARCHAR(30) NOT NULL, + parameters_json TEXT, + file_path VARCHAR(500), + file_size INTEGER, + error_msg TEXT, + result_payload TEXT, + started_at TIMESTAMP WITHOUT TIME ZONE, + finished_at TIMESTAMP WITHOUT TIME ZONE +); + +CREATE INDEX IF NOT EXISTS ix_ppt_generation_runs_report_type + ON ppt_generation_runs (report_type); + +CREATE INDEX IF NOT EXISTS ix_ppt_generation_runs_started_at + ON ppt_generation_runs (started_at); + +CREATE INDEX IF NOT EXISTS ix_ppt_generation_runs_schedule_kind + ON ppt_generation_runs (schedule_kind); diff --git a/routes/admin_observability_routes.py b/routes/admin_observability_routes.py index bb5db5d..f21f338 100644 --- a/routes/admin_observability_routes.py +++ b/routes/admin_observability_routes.py @@ -1850,7 +1850,11 @@ def ppt_audit_generate_missing(): 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) + result = start_defined_ppt_generation_background( + report_types=report_types, + schedule_kind='manual', + 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 @@ -2210,15 +2214,19 @@ def ppt_audit_history(): error = None month_arg = request.args.get('month', '').strip() report_type = request.args.get('report_type', 'daily').strip().lower() or 'daily' - report_type_options = [ - {'key': 'daily', 'label': '每日日報', 'prefix': 'ocbot_daily_'}, - {'key': 'weekly', 'label': '週報', 'prefix': 'ocbot_weekly_'}, - {'key': 'monthly', 'label': '月報', 'prefix': 'ocbot_monthly_'}, - {'key': 'strategy', 'label': '策略', 'prefix': 'ocbot_strategy_'}, - {'key': 'competitor', 'label': '競品', 'prefix': 'ocbot_competitor_'}, - {'key': 'promo', 'label': '促銷', 'prefix': 'ocbot_promo_'}, - {'key': 'all', 'label': '全部', 'prefix': 'all'}, - ] + try: + from services.ppt_auto_generation_service import get_report_type_options + report_type_options = get_report_type_options() + except Exception: + report_type_options = [ + {'key': 'daily', 'label': '每日日報', 'prefix': 'ocbot_daily_'}, + {'key': 'weekly', 'label': '週報', 'prefix': 'ocbot_weekly_'}, + {'key': 'monthly', 'label': '月報', 'prefix': 'ocbot_monthly_'}, + {'key': 'strategy', 'label': '策略', 'prefix': 'ocbot_strategy_'}, + {'key': 'competitor', 'label': '競品', 'prefix': 'ocbot_competitor_'}, + {'key': 'promo', 'label': '促銷', 'prefix': 'ocbot_promo_'}, + {'key': 'all', 'label': '全部', 'prefix': 'all'}, + ] report_type_map = {opt['key']: opt for opt in report_type_options} if report_type not in report_type_map: report_type = 'daily' diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index c994ee8..35caf3f 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -2863,20 +2863,30 @@ def _store_ppt_cache(report_type: str, parameters: dict, file_path: str, cached_ """儲存 PPT 快取資料,回傳 file_path。""" from database.manager import DatabaseManager from database.ppt_reports import PPTReport + from services.ppt_generator import get_template_version now = datetime.now(TAIPEI_TZ) params = _normalize_ppt_parameters(parameters) expire_at = now + timedelta(hours=PPT_CACHE_TTL_HOURS) - try: - cached_data = json.dumps(cached_payload, ensure_ascii=False) - except Exception: - cached_data = json.dumps({'error': 'cached_data serialization failed'}, ensure_ascii=False) + payload = dict(cached_payload or {}) + payload.setdefault('report_type', report_type) + payload.setdefault('parameters', parameters) + payload.setdefault('template_version', get_template_version(report_type)) + payload.setdefault('stored_at', now.strftime('%Y-%m-%d %H:%M:%S')) + payload.setdefault('file_path', file_path) try: file_size = os.path.getsize(file_path) + payload.setdefault('file_size', file_size) except OSError: file_size = None + payload.setdefault('file_size', None) + + try: + cached_data = json.dumps(payload, ensure_ascii=False, default=str) + except Exception: + cached_data = json.dumps({'error': 'cached_data serialization failed'}, ensure_ascii=False) session = DatabaseManager().get_session() try: @@ -3013,6 +3023,7 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, + 'source_data': db_data, 'mcp': mcp_text, }) return ppt_path @@ -3054,6 +3065,7 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, + 'source_data': db_data, 'mcp': mcp_text, }) return ppt_path @@ -3127,6 +3139,7 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, + 'source_data': db_data, 'mcp': mcp_text, }) return ppt_path @@ -3236,6 +3249,7 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, + 'source_data': db_data, 'mcp': mcp_text, }) return ppt_path @@ -3338,6 +3352,7 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, + 'source_data': db_data, 'mcp': mcp_text_c, }) return ppt_path @@ -3387,6 +3402,7 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, 'parameters': params, 'data_summary': data_summary_p, 'analysis': ai_text_p, + 'source_data': data_p, 'mcp': mcp_text, }) return ppt_path @@ -3481,6 +3497,7 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, + 'source_data': curr, 'mcp': mcp_text, }) return ppt_path @@ -3524,7 +3541,8 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, ppt_path = generate_competitor_v4_ppt(period_label, c5_data, ai_text) _store_ppt_cache('competitor_v4', params, ppt_path, { 'report_type': 'competitor_v4', 'parameters': params, - 'data_summary': data_summary, 'analysis': ai_text, 'mcp': '', + 'data_summary': data_summary, 'analysis': ai_text, + 'source_data': c5_data, 'mcp': '', }) return ppt_path @@ -3577,7 +3595,8 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, ppt_path = generate_price_elasticity_ppt(pe_data, ai_text) _store_ppt_cache('price_elasticity', params, ppt_path, { 'report_type': 'price_elasticity', 'parameters': params, - 'data_summary': data_summary, 'analysis': ai_text, 'mcp': '', + 'data_summary': data_summary, 'analysis': ai_text, + 'source_data': pe_data, 'mcp': '', }) return ppt_path @@ -3633,7 +3652,8 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, ppt_path = generate_market_intel_weekly_ppt(week_label, sections, ai_text) _store_ppt_cache('market_intel', params, ppt_path, { 'report_type': 'market_intel', 'parameters': params, - 'data_summary': data_summary, 'analysis': ai_text, 'mcp': '', + 'data_summary': data_summary, 'analysis': ai_text, + 'source_data': sections, 'mcp': '', }) return ppt_path @@ -3685,7 +3705,8 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, ppt_path = generate_new_product_ppt(np_data, ai_text) _store_ppt_cache('new_product', params, ppt_path, { 'report_type': 'new_product', 'parameters': params, - 'data_summary': data_summary, 'analysis': ai_text, 'mcp': mcp_text, + 'data_summary': data_summary, 'analysis': ai_text, + 'source_data': np_data, 'mcp': mcp_text, }) return ppt_path @@ -3777,7 +3798,8 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, ) _store_ppt_cache('promo_compare', params, ppt_path, { 'report_type': 'promo_compare', 'parameters': params, - 'data_summary': data_summary, 'analysis': ai_text, 'mcp': '', + 'data_summary': data_summary, 'analysis': ai_text, + 'source_data': {'promos': all_promos, 'rankings': rankings}, 'mcp': '', }) return ppt_path @@ -3838,7 +3860,8 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, ppt_path = generate_forecast_pre_event_ppt(event_name, event_date, fc_data, ai_text) _store_ppt_cache('forecast_pre_event', params, ppt_path, { 'report_type': 'forecast_pre_event', 'parameters': params, - 'data_summary': data_summary, 'analysis': ai_text, 'mcp': mcp_text, + 'data_summary': data_summary, 'analysis': ai_text, + 'source_data': fc_data, 'mcp': mcp_text, }) return ppt_path @@ -3902,7 +3925,8 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, ppt_path = generate_customer_analytics_ppt(period_label, cust_data, ai_text) _store_ppt_cache('customer', params, ppt_path, { 'report_type': 'customer', 'parameters': params, - 'data_summary': data_summary, 'analysis': ai_text, 'mcp': mcp_text, + 'data_summary': data_summary, 'analysis': ai_text, + 'source_data': cust_data, 'mcp': mcp_text, }) return ppt_path @@ -3961,7 +3985,8 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, ppt_path = generate_category_deep_ppt(cat, cat_data, ai_text) _store_ppt_cache('category', params, ppt_path, { 'report_type': 'category', 'parameters': params, - 'data_summary': data_summary, 'analysis': ai_text, 'mcp': mcp_text, + 'data_summary': data_summary, 'analysis': ai_text, + 'source_data': cat_data, 'mcp': mcp_text, }) return ppt_path @@ -4109,6 +4134,7 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, + 'source_data': db_data_pr, 'mcp': mcp_text, }) return ppt_path diff --git a/run_scheduler.py b/run_scheduler.py index f8b6319..08960d3 100644 --- a/run_scheduler.py +++ b/run_scheduler.py @@ -8,9 +8,12 @@ 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_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) + 每 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_daily(20:30)、ppt_vision_audit(22:00)、daily_token_report(23:55) + 每 1 週 :weekly_strategy(週一 06:00)、ppt_auto_generation_weekly(週一 20:40) + 每 1 月 :monthly_report(每月1日 07:00)、ppt_auto_generation_monthly(每月1日 20:50) + 每 1 季 :ppt_auto_generation_quarterly(1/4/7/10 月 1 日 21:00) + 每半年 :ppt_auto_generation_half_yearly(1/7 月 1 日 21:10) + 每 1 年 :ppt_auto_generation_annual(1 月 1 日 21:20) """ import asyncio import logging @@ -182,8 +185,43 @@ def _register_schedules(): 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 定義報表)") + schedule.every().day.at("20:30").do(lambda: run_ppt_auto_generation_task("daily")) + logger.info("📅 每日 20:30:ppt_auto_generation_daily(日報)") + + schedule.every().monday.at("20:40").do(lambda: run_ppt_auto_generation_task("weekly")) + logger.info("📅 每週一 20:40:ppt_auto_generation_weekly(週報 / 市場情報)") + + def _ppt_monthly_gate(): + from datetime import datetime as _dt + if _dt.now().day == 1: + run_ppt_auto_generation_task("monthly") + + def _ppt_quarterly_gate(): + from datetime import datetime as _dt + now = _dt.now() + if now.day == 1 and now.month in (1, 4, 7, 10): + run_ppt_auto_generation_task("quarterly") + + def _ppt_half_yearly_gate(): + from datetime import datetime as _dt + now = _dt.now() + if now.day == 1 and now.month in (1, 7): + run_ppt_auto_generation_task("half_yearly") + + def _ppt_annual_gate(): + from datetime import datetime as _dt + now = _dt.now() + if now.day == 1 and now.month == 1: + run_ppt_auto_generation_task("annual") + + schedule.every().day.at("20:50").do(_ppt_monthly_gate) + logger.info("📅 每月1日 20:50:ppt_auto_generation_monthly(月報與管理型簡報)") + schedule.every().day.at("21:00").do(_ppt_quarterly_gate) + logger.info("📅 每季首月1日 21:00:ppt_auto_generation_quarterly(季報)") + schedule.every().day.at("21:10").do(_ppt_half_yearly_gate) + logger.info("📅 每半年首月1日 21:10:ppt_auto_generation_half_yearly(半年報)") + schedule.every().day.at("21:20").do(_ppt_annual_gate) + logger.info("📅 每年1月1日 21:20:ppt_auto_generation_annual(年報)") # Phase 26: PPT 視覺審核(每日 22:00 掃當天新生 .pptx,有 issues 才推 Telegram) schedule.every().day.at("22:00").do(run_ppt_vision_audit) diff --git a/scheduler.py b/scheduler.py index 661b856..7fc5235 100644 --- a/scheduler.py +++ b/scheduler.py @@ -2783,28 +2783,31 @@ def run_monthly_report_task(): logging.error(f"[Scheduler] [MonthlyReport] auto_heal_service 失敗: {_heal_e}") -def run_ppt_auto_generation_task(): - """每日補齊觀測台定義中的 PPT 簡報。 +def run_ppt_auto_generation_task(schedule_kind=None): + """依日/週/月/季/半年/年度節奏補齊 PPT 簡報。 - 22:00 的 ppt_vision_audit 只負責視覺審核;這個任務先把 daily / - weekly / monthly / strategy / competitor / promo 產出補齊,讓審核頁不是 - 被動等 Telegram 人工觸發。 + 22:00 的 ppt_vision_audit 只負責視覺審核;這個任務先依固定週期產出 + 已定義的簡報,並把每次嘗試寫入 ppt_generation_runs,成功檔案寫入 + ppt_reports.cached_data。 """ try: - from services.ppt_auto_generation_service import generate_defined_ppt_reports + from services.ppt_auto_generation_service import generate_scheduled_ppt_reports - result = generate_defined_ppt_reports() + result = generate_scheduled_ppt_reports(schedule_kind=schedule_kind) logging.info( - "[Scheduler] [PPTAutoGeneration] status=%s ready=%s errors=%s", + "[Scheduler] [PPTAutoGeneration] kind=%s status=%s ready=%s errors=%s", + schedule_kind or "due", result.get("status"), result.get("ready", 0), result.get("errors", 0), ) - _save_stats("ppt_auto_generation", result) + stat_key = f"ppt_auto_generation_{schedule_kind}" if schedule_kind else "ppt_auto_generation" + _save_stats(stat_key, 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)}) + stat_key = f"ppt_auto_generation_{schedule_kind}" if schedule_kind else "ppt_auto_generation" + _save_stats(stat_key, {"status": "Error", "error": str(e), "schedule_kind": schedule_kind}) try: from services.event_router import notify_failure notify_failure( diff --git a/services/ppt_auto_generation_service.py b/services/ppt_auto_generation_service.py index d2b0207..0fcf373 100644 --- a/services/ppt_auto_generation_service.py +++ b/services/ppt_auto_generation_service.py @@ -8,6 +8,8 @@ defined deck set before the audit window. from __future__ import annotations +import calendar +import json import os import threading from dataclasses import asdict, dataclass @@ -22,24 +24,73 @@ from database.manager import get_session TAIPEI_TZ = timezone(timedelta(hours=8)) -DEFINED_REPORT_TYPES = ("daily", "weekly", "monthly", "strategy", "competitor", "promo") +DEFINED_REPORT_TYPES = ( + "daily", + "weekly", + "monthly", + "quarterly", + "half_yearly", + "annual", + "ttm", + "strategy", + "competitor", + "competitor_v4", + "promo", + "promo_compare", + "forecast_pre_event", + "vendor", + "category", + "customer", + "new_product", + "market_intel", + "price_elasticity", +) REPORT_TYPE_LABELS = { "daily": "每日日報", "weekly": "週報", "monthly": "月報", + "quarterly": "季報", + "half_yearly": "半年報", + "annual": "年報", + "ttm": "TTM 滾動 12 月", "strategy": "策略", "competitor": "競品", + "competitor_v4": "競業五力", "promo": "促銷", + "promo_compare": "多活動比較", + "forecast_pre_event": "檔期前瞻", + "vendor": "廠商", + "category": "品類", + "customer": "客戶", + "new_product": "新品追蹤", + "market_intel": "市場情報", + "price_elasticity": "價格甜蜜點", } -REPORT_PREFIXES = { - "daily": "ocbot_daily_", - "weekly": "ocbot_weekly_", - "monthly": "ocbot_monthly_", - "strategy": "ocbot_strategy_", - "competitor": "ocbot_competitor_", - "promo": "ocbot_promo_", +REPORT_PREFIXES = {key: f"ocbot_{key}_" for key in DEFINED_REPORT_TYPES} + +SCHEDULE_PROFILES = { + "daily": ("daily",), + "weekly": ("weekly", "market_intel"), + "monthly": ( + "monthly", + "strategy", + "competitor", + "competitor_v4", + "promo", + "promo_compare", + "forecast_pre_event", + "vendor", + "category", + "customer", + "new_product", + "price_elasticity", + "ttm", + ), + "quarterly": ("quarterly",), + "half_yearly": ("half_yearly",), + "annual": ("annual",), } _RUN_LOCK = threading.Lock() @@ -53,6 +104,8 @@ class PPTAutoJob: sub_type: str sub_arg: str target_date: str + target_label: str + expected_params: dict def _truthy(value: str | None, default: bool = False) -> bool: @@ -104,28 +157,271 @@ def _normalise_date(value: str | None) -> str: return (datetime.now(TAIPEI_TZ) - timedelta(days=1)).strftime("%Y/%m/%d") +def _target_datetime(latest_date: str | None = None) -> datetime: + target = _normalise_date(latest_date or _latest_sales_date()) + return datetime.strptime(target, "%Y/%m/%d") + + +def _month_bounds(target_dt: datetime) -> tuple[str, str, str]: + start = f"{target_dt.year}/{target_dt.month:02d}/01" + last_day = calendar.monthrange(target_dt.year, target_dt.month)[1] + end = f"{target_dt.year}/{target_dt.month:02d}/{last_day:02d}" + label = f"{target_dt.year}/{target_dt.month:02d}" + return start, end, label + + +def _quarter_label(target_dt: datetime) -> str: + quarter = ((target_dt.month - 1) // 3) + 1 + return f"{target_dt.year} Q{quarter}" + + +def _half_year_label(target_dt: datetime) -> str: + half = 1 if target_dt.month <= 6 else 2 + return f"{target_dt.year} H{half}" + + +def _ttm_label(target_dt: datetime) -> str: + ttm_start = target_dt.date().replace(day=1) - timedelta(days=365) + ttm_start = ttm_start.replace(day=1) + return f"TTM {ttm_start.strftime('%Y/%m/%d')[:7]}~{target_dt.strftime('%Y/%m/%d')[:7]}" + + +def _week_label(target_dt: datetime) -> str: + week_start = target_dt.date() - timedelta(days=target_dt.weekday()) + return f"{week_start.strftime('%Y/%m/%d')} 起一週" + + +def _default_category() -> str: + return os.getenv("PPT_AUTO_DEFAULT_CATEGORY", "美妝保養").strip() or "美妝保養" + + +def _default_forecast_event(target_dt: datetime) -> tuple[str, str]: + events = [ + ("618", f"{target_dt.year}/06/18"), + ("七夕", f"{target_dt.year}/08/19"), + ("雙11", f"{target_dt.year}/11/11"), + ("雙12", f"{target_dt.year}/12/12"), + ("母親節", f"{target_dt.year + 1}/05/10"), + ] + today = target_dt.date() + for name, date_str in events: + try: + if datetime.strptime(date_str, "%Y/%m/%d").date() >= today: + return name, date_str + except ValueError: + continue + return "雙11", f"{target_dt.year}/11/11" + + +def get_report_type_options() -> list[dict]: + return [ + { + "key": key, + "label": REPORT_TYPE_LABELS[key], + "prefix": REPORT_PREFIXES[key], + } + for key in DEFINED_REPORT_TYPES + ] + [{"key": "all", "label": "全部", "prefix": "all"}] + + 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") + target_dt = _target_datetime(latest_date) + target = target_dt.strftime("%Y/%m/%d") month_arg = target_dt.strftime("%Y/%m") + month_start, month_end, month_label = _month_bounds(target_dt) + quarter_label = _quarter_label(target_dt) + half_label = _half_year_label(target_dt) + ttm_label = _ttm_label(target_dt) + week_label = _week_label(target_dt) + category = _default_category() + forecast_event, forecast_date = _default_forecast_event(target_dt) promo_start = (target_dt - timedelta(days=6)).strftime("%Y/%m/%d") + promo_prev_start = (target_dt - timedelta(days=13)).strftime("%Y/%m/%d") + promo_prev_end = (target_dt - timedelta(days=7)).strftime("%Y/%m/%d") promo_arg = f"{promo_start}-{target}" + promo_compare_arg = f"近7日:{promo_start}-{target}|前7日:{promo_prev_start}-{promo_prev_end}" 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), + "daily": PPTAutoJob("daily", "每日日報", "daily", target, target, target, { + "report_type": "daily", "date": target, + }), + "weekly": PPTAutoJob("weekly", "週報", "weekly", "", target, "最新 7 日", { + "report_type": "weekly", + }), + "monthly": PPTAutoJob("monthly", "月報", "monthly", month_arg, target, month_label, { + "report_type": "monthly", "month": month_arg, + }), + "quarterly": PPTAutoJob("quarterly", "季報", "quarterly", quarter_label.replace(" ", "/"), target, quarter_label, { + "report_type": "quarterly", "period": quarter_label, + }), + "half_yearly": PPTAutoJob("half_yearly", "半年報", "half_yearly", half_label.replace(" ", "/"), target, half_label, { + "report_type": "half_yearly", "period": half_label, + }), + "annual": PPTAutoJob("annual", "年報", "annual", str(target_dt.year), target, str(target_dt.year), { + "report_type": "annual", "period": str(target_dt.year), + }), + "ttm": PPTAutoJob("ttm", "TTM 滾動 12 月", "ttm", "", target, ttm_label, { + "report_type": "ttm", "period": ttm_label, + }), + "strategy": PPTAutoJob("strategy", "策略(月)", "strategy", month_arg, target, f"{month_label} 月策略", { + "report_type": "strategy", "start": month_start, "end": month_end, "label": f"{month_label} 月策略", + }), + "competitor": PPTAutoJob("competitor", "競品(月)", "competitor", "monthly", target, f"{month_label} 月比較", { + "report_type": "competitor", "start": month_start, "end": target, "label": f"{month_label} 月比較", + }), + "competitor_v4": PPTAutoJob("competitor_v4", "競業五力", "competitor_v4", "PChome", target, "PChome 近 30 天", { + "report_type": "competitor_v4", "competitor": "PChome", + }), + "promo": PPTAutoJob("promo", "促銷(近 7 日)", "promo", promo_arg, target, promo_arg, { + "report_type": "promo", "start": promo_start, "end": target, "label": promo_arg, + }), + "promo_compare": PPTAutoJob("promo_compare", "多活動比較", "promo_compare", promo_compare_arg, target, "近7日 vs 前7日", { + "report_type": "promo_compare", "promos": promo_compare_arg, + }), + "forecast_pre_event": PPTAutoJob("forecast_pre_event", "檔期前瞻", "forecast_pre_event", f"{forecast_event} {forecast_date}", target, f"{forecast_event} {forecast_date}", { + "report_type": "forecast_pre_event", "event": forecast_event, "date": forecast_date, + }), + "vendor": PPTAutoJob("vendor", "廠商", "vendor", month_arg, target, month_label, { + "report_type": "vendor", "period": month_label, + }), + "category": PPTAutoJob("category", f"品類({category})", "category", f"{category} 90", target, f"{category} 近 90 天", { + "report_type": "category", "category": category, "days": 90, + }), + "customer": PPTAutoJob("customer", "客戶", "customer", month_arg, target, month_label, { + "report_type": "customer", "period": month_label, + }), + "new_product": PPTAutoJob("new_product", "新品追蹤", "new_product", "30", target, "近 30 天", { + "report_type": "new_product", "days": 30, + }), + "market_intel": PPTAutoJob("market_intel", "市場情報", "market_intel", "", target, week_label, { + "report_type": "market_intel", "week": week_label, + }), + "price_elasticity": PPTAutoJob("price_elasticity", "價格甜蜜點", "price_elasticity", "90", target, "全平台近 90 天", { + "report_type": "price_elasticity", "category": "all", "days": 90, + }), } return [job_map[key] for key in _parse_report_types(report_types)] +def _parse_cache_params(raw: str | None) -> dict: + if not raw: + return {} + try: + data = json.loads(raw) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _params_match(actual: dict, expected: dict) -> bool: + return all(actual.get(key) == value for key, value in expected.items()) + + +def _ensure_generation_log_table() -> None: + session = get_session() + try: + session.execute( + sa_text( + """ + CREATE TABLE IF NOT EXISTS ppt_generation_runs ( + id SERIAL PRIMARY KEY, + schedule_kind VARCHAR(40) NOT NULL, + report_type VARCHAR(50) NOT NULL, + target_label VARCHAR(160), + status VARCHAR(30) NOT NULL, + parameters_json TEXT, + file_path VARCHAR(500), + file_size INTEGER, + error_msg TEXT, + result_payload TEXT, + started_at TIMESTAMP WITHOUT TIME ZONE, + finished_at TIMESTAMP WITHOUT TIME ZONE + ) + """ + ) + ) + session.execute( + sa_text( + "CREATE INDEX IF NOT EXISTS ix_ppt_generation_runs_report_type " + "ON ppt_generation_runs (report_type)" + ) + ) + session.execute( + sa_text( + "CREATE INDEX IF NOT EXISTS ix_ppt_generation_runs_started_at " + "ON ppt_generation_runs (started_at)" + ) + ) + session.execute( + sa_text( + "CREATE INDEX IF NOT EXISTS ix_ppt_generation_runs_schedule_kind " + "ON ppt_generation_runs (schedule_kind)" + ) + ) + session.commit() + except Exception: + session.rollback() + finally: + session.close() + + +def _log_generation_run( + job: PPTAutoJob, + *, + schedule_kind: str, + status: str, + started_at: datetime, + finished_at: datetime, + path: str | None = None, + error: str | None = None, + result_payload: dict | None = None, +) -> None: + try: + _ensure_generation_log_table() + file_size = os.path.getsize(path) if path and os.path.exists(path) else None + session = get_session() + try: + session.execute( + sa_text( + """ + INSERT INTO ppt_generation_runs ( + schedule_kind, report_type, target_label, status, + parameters_json, file_path, file_size, error_msg, + result_payload, started_at, finished_at + ) + VALUES ( + :schedule_kind, :report_type, :target_label, :status, + :parameters_json, :file_path, :file_size, :error_msg, + :result_payload, :started_at, :finished_at + ) + """ + ), + { + "schedule_kind": schedule_kind, + "report_type": job.report_type, + "target_label": job.target_label, + "status": status, + "parameters_json": json.dumps(job.expected_params, ensure_ascii=False, sort_keys=True, default=str), + "file_path": path, + "file_size": file_size, + "error_msg": error, + "result_payload": json.dumps(result_payload or {}, ensure_ascii=False, default=str), + "started_at": started_at.replace(tzinfo=None), + "finished_at": finished_at.replace(tzinfo=None), + }, + ) + session.commit() + finally: + session.close() + except Exception: + # 排程紀錄不能阻斷簡報產出主流程。 + pass + + def get_defined_report_coverage( *, month_start: datetime, @@ -133,9 +429,14 @@ def get_defined_report_coverage( reports_dir: str | os.PathLike[str] | None = None, report_types: Iterable[str] | str | None = None, ) -> dict: - selected_types = _parse_report_types(report_types) + jobs = build_defined_ppt_jobs(report_types=report_types) + selected_types = [job.report_type for job in jobs] counts = {key: 0 for key in selected_types} + exact_counts = {key: 0 for key in selected_types} sources = {key: set() for key in selected_types} + latest_generated_at = {key: None for key in selected_types} + latest_file_path = {key: None for key in selected_types} + expected_params = {job.report_type: job.expected_params for job in jobs} try: session = get_session() @@ -143,20 +444,26 @@ def get_defined_report_coverage( rows = session.execute( sa_text( """ - SELECT report_type, COUNT(*) + SELECT report_type, parameters, file_path, generated_at 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: + for report_type, parameters, file_path, generated_at 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") + counts[report_type] += 1 + sources[report_type].add("database") + if ( + latest_generated_at[report_type] is None + or (generated_at and generated_at > latest_generated_at[report_type]) + ): + latest_generated_at[report_type] = generated_at + latest_file_path[report_type] = file_path + if _params_match(_parse_cache_params(parameters), expected_params[report_type]): + exact_counts[report_type] += 1 finally: session.close() except Exception: @@ -179,16 +486,28 @@ def get_defined_report_coverage( if path.name.startswith(REPORT_PREFIXES[report_type]): counts[report_type] += 1 sources[report_type].add("filesystem") + if latest_generated_at[report_type] is None or mtime > latest_generated_at[report_type].timestamp(): + latest_generated_at[report_type] = datetime.fromtimestamp(mtime) + latest_file_path[report_type] = str(path) items = [ { - "key": key, - "label": REPORT_TYPE_LABELS[key], - "count": counts[key], - "ready": counts[key] > 0, - "sources": sorted(sources[key]), + "key": job.report_type, + "label": job.label, + "target_label": job.target_label, + "count": counts[job.report_type], + "exact_count": exact_counts[job.report_type], + "ready": exact_counts[job.report_type] > 0, + "has_other_versions": counts[job.report_type] > 0 and exact_counts[job.report_type] == 0, + "sources": sorted(sources[job.report_type]), + "latest_generated_at": ( + latest_generated_at[job.report_type].strftime("%Y-%m-%d %H:%M") + if latest_generated_at[job.report_type] else None + ), + "latest_file_path": latest_file_path[job.report_type], + "expected_params": job.expected_params, } - for key in selected_types + for job in jobs ] missing = [item for item in items if not item["ready"]] return { @@ -228,6 +547,7 @@ def _generate_job(job: PPTAutoJob) -> str | None: def generate_defined_ppt_reports( *, report_types: Iterable[str] | str | None = None, + schedule_kind: str = "manual", force: bool = False, dry_run: bool = False, max_jobs: int | None = None, @@ -269,14 +589,33 @@ def generate_defined_ppt_reports( try: for job in jobs: item = asdict(job) + job_started_at = datetime.now(TAIPEI_TZ) 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" + _log_generation_run( + job, + schedule_kind=schedule_kind, + status=item["status"], + path=path, + started_at=job_started_at, + finished_at=datetime.now(TAIPEI_TZ), + result_payload=item, + ) except Exception as exc: item["status"] = "error" item["error"] = f"{type(exc).__name__}: {str(exc)[:220]}" + _log_generation_run( + job, + schedule_kind=schedule_kind, + status="error", + error=item["error"], + started_at=job_started_at, + finished_at=datetime.now(TAIPEI_TZ), + result_payload=item, + ) results.append(item) finished_at = datetime.now(TAIPEI_TZ) @@ -299,6 +638,7 @@ def generate_defined_ppt_reports( def start_defined_ppt_generation_background( *, report_types: Sequence[str] | str | None = None, + schedule_kind: str = "manual", force: bool = False, ) -> dict: if _RUN_LOCK.locked(): @@ -310,7 +650,11 @@ def start_defined_ppt_generation_background( } def _run(): - generate_defined_ppt_reports(report_types=report_types, force=force) + generate_defined_ppt_reports( + report_types=report_types, + schedule_kind=schedule_kind, + force=force, + ) thread = threading.Thread(target=_run, name="ppt-auto-generation", daemon=True) thread.start() @@ -319,8 +663,61 @@ def start_defined_ppt_generation_background( "status": "queued", "message": "PPT auto-generation queued.", "report_types": _parse_report_types(report_types), + "schedule_kind": schedule_kind, } def get_last_generation_status() -> dict | None: return _LAST_RUN + + +def get_due_schedule_kinds(now: datetime | None = None) -> list[str]: + current = now or datetime.now(TAIPEI_TZ) + kinds = ["daily"] + if current.weekday() == 0: + kinds.append("weekly") + if current.day == 1: + kinds.append("monthly") + if current.day == 1 and current.month in (1, 4, 7, 10): + kinds.append("quarterly") + if current.day == 1 and current.month in (1, 7): + kinds.append("half_yearly") + if current.day == 1 and current.month == 1: + kinds.append("annual") + return kinds + + +def generate_scheduled_ppt_reports( + *, + schedule_kind: str | None = None, + force: bool = False, +) -> dict: + kinds = [schedule_kind] if schedule_kind else get_due_schedule_kinds() + runs = [] + for kind in kinds: + report_types = SCHEDULE_PROFILES.get(kind) + if not report_types: + runs.append({ + "ok": False, + "status": "unknown_schedule_kind", + "schedule_kind": kind, + "jobs": [], + }) + continue + runs.append( + generate_defined_ppt_reports( + report_types=report_types, + schedule_kind=kind, + force=force, + ) + ) + + return { + "ok": all(run.get("ok", False) for run in runs) if runs else True, + "status": "completed", + "schedule_kinds": kinds, + "runs": runs, + "ready": sum(int(run.get("ready") or 0) for run in runs), + "errors": sum(int(run.get("errors") or 0) for run in runs), + "jobs": [job for run in runs for job in run.get("jobs", [])], + } diff --git a/templates/admin/ppt_audit_history.html b/templates/admin/ppt_audit_history.html index b8eb92e..5ed336e 100644 --- a/templates/admin/ppt_audit_history.html +++ b/templates/admin/ppt_audit_history.html @@ -91,10 +91,13 @@ {% for item in auto_generation_items %}
{{ item.label }} - - {{ '已產生' if item.ready else '待補齊' }} + + {% if item.ready %}目標已產生{% elif item.has_other_versions %}有其他版本{% else %}待補齊{% endif %} - {{ item.count }} 筆{% if item.sources %} · {{ item.sources|join(' + ') }}{% endif %} + + {{ item.target_label or '最新資料' }} · {{ item.count }} 筆{% if item.sources %} · {{ item.sources|join(' + ') }}{% endif %} + {% if item.latest_generated_at %}
最近 {{ item.latest_generated_at }}{% endif %} +
{% endfor %} diff --git a/tests/test_ppt_auto_generation_service.py b/tests/test_ppt_auto_generation_service.py index bb64a3c..6ffa48d 100644 --- a/tests/test_ppt_auto_generation_service.py +++ b/tests/test_ppt_auto_generation_service.py @@ -1,4 +1,5 @@ from datetime import datetime +import json def test_build_defined_ppt_jobs_uses_latest_date(): @@ -7,12 +8,26 @@ def test_build_defined_ppt_jobs_uses_latest_date(): 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 list(by_type) == [ + "daily", "weekly", "monthly", "quarterly", "half_yearly", "annual", "ttm", + "strategy", "competitor", "competitor_v4", "promo", "promo_compare", + "forecast_pre_event", "vendor", "category", "customer", "new_product", + "market_intel", "price_elasticity", + ] 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["quarterly"].sub_arg == "2026/Q2" + assert by_type["half_yearly"].sub_arg == "2026/H1" + assert by_type["annual"].sub_arg == "2026" + assert by_type["strategy"].sub_arg == "2026/05" assert by_type["competitor"].sub_arg == "monthly" assert by_type["promo"].sub_arg == "2026/05/05-2026/05/11" + assert by_type["strategy"].expected_params == { + "report_type": "strategy", + "start": "2026/05/01", + "end": "2026/05/31", + "label": "2026/05 月策略", + } def test_auto_generation_respects_disabled_flag(monkeypatch): @@ -48,7 +63,20 @@ def test_coverage_marks_ready_from_database(monkeypatch): class _Rows: def fetchall(self): - return [("daily", 2), ("monthly", 1)] + return [ + ( + "daily", + json.dumps({"report_type": "daily", "date": "2026/05/11"}), + "/tmp/ocbot_daily_ok.pptx", + datetime(2026, 5, 11, 20, 30), + ), + ( + "monthly", + json.dumps({"report_type": "monthly", "month": "2026/05"}), + "/tmp/ocbot_monthly_ok.pptx", + datetime(2026, 5, 11, 20, 30), + ), + ] class _Session: def execute(self, *_args, **_kwargs): @@ -58,6 +86,7 @@ def test_coverage_marks_ready_from_database(monkeypatch): return None monkeypatch.setattr(svc, "get_session", lambda: _Session()) + monkeypatch.setattr(svc, "_latest_sales_date", lambda: "2026-05-11") monkeypatch.setenv("PPT_AUTO_GENERATION_ENABLED", "true") result = svc.get_defined_report_coverage( @@ -72,3 +101,44 @@ def test_coverage_marks_ready_from_database(monkeypatch): assert by_key["monthly"]["ready"] is True assert by_key["weekly"]["ready"] is False assert result["missing_count"] == 1 + + +def test_due_schedule_kinds_include_periodic_boundaries(): + from services.ppt_auto_generation_service import get_due_schedule_kinds + + assert get_due_schedule_kinds(datetime(2026, 5, 18)) == ["daily", "weekly"] + assert get_due_schedule_kinds(datetime(2026, 7, 1)) == [ + "daily", + "monthly", + "quarterly", + "half_yearly", + ] + assert get_due_schedule_kinds(datetime(2026, 1, 1)) == [ + "daily", + "monthly", + "quarterly", + "half_yearly", + "annual", + ] + + +def test_scheduled_generation_uses_profile_without_generating(monkeypatch): + from services import ppt_auto_generation_service as svc + + calls = [] + + def fake_generate_defined_ppt_reports(**kwargs): + calls.append(kwargs) + return {"ok": True, "ready": 2, "errors": 0, "jobs": [{"report_type": "weekly"}]} + + monkeypatch.setattr(svc, "generate_defined_ppt_reports", fake_generate_defined_ppt_reports) + + result = svc.generate_scheduled_ppt_reports(schedule_kind="weekly", force=True) + + assert result["ok"] is True + assert result["schedule_kinds"] == ["weekly"] + assert calls == [{ + "report_types": ("weekly", "market_intel"), + "schedule_kind": "weekly", + "force": True, + }] diff --git a/web/static/css/page-ppt-audit-history.css b/web/static/css/page-ppt-audit-history.css index e78e0ee..6d59d1c 100644 --- a/web/static/css/page-ppt-audit-history.css +++ b/web/static/css/page-ppt-audit-history.css @@ -134,6 +134,12 @@ gap: var(--momo-space-3, 12px); } +.ppt-auto-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--momo-space-3, 12px); +} + .ppt-mini, .fix-card { padding: var(--momo-space-3, 12px); @@ -186,6 +192,10 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } + .ppt-auto-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .ppt-grid { grid-template-columns: 1fr; } @@ -193,6 +203,7 @@ @media (max-width: 760px) { .ppt-command, + .ppt-auto-grid, .ppt-mini-grid { grid-template-columns: 1fr; }