feat: schedule full ppt auto generation cadence
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
OoO
2026-05-18 14:22:09 +08:00
parent bb6a862dbe
commit cb02cd350f
12 changed files with 689 additions and 74 deletions

View File

@@ -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_* 表

View File

@@ -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"<PPTGenerationRun(kind={self.schedule_kind}, "
f"type={self.report_type}, status={self.status})>"
)

View File

@@ -106,7 +106,7 @@ SQL漏斗(~300筆)
- CD rebuild 模式必須先 build image 成功,再短暫 stop/rm/recreate 三應用容器,避免 no-cache build 造成長時間 502。
- ElephantAlpha 使用 NVIDIA NIM hosted APIproduction 預設模型為 `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`
---

View File

@@ -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);

View File

@@ -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'

View File

@@ -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

View File

@@ -8,9 +8,12 @@ run_scheduler.py — momo-scheduler 容器入口點
每 4 小時competitor_price_feeder、icaim_analysis
每 6 小時quality_rescore
每 12 小時dedup_batch
每 1 天 db_backup03:00、cleanup_agent_context03:30、backup_monitor04:00、daily_report09:00、roi_monthly_report gate09:05、ai_smoke_summary09:10、observability_daily_summary09:30、pchome_match_backfill10:30、openclaw_meta_analysis12:00, Phase 4 降頻、ppt_auto_generation20:30、ppt_vision_audit22:00、daily_token_report23:55
每 1 週 weekly_strategy週一 06:00
每 1 月 monthly_report每月1日 07:00
每 1 天 db_backup03:00、cleanup_agent_context03:30、backup_monitor04:00、daily_report09:00、roi_monthly_report gate09:05、ai_smoke_summary09:10、observability_daily_summary09:30、pchome_match_backfill10:30、openclaw_meta_analysis12:00, Phase 4 降頻、ppt_auto_generation_daily20:30、ppt_vision_audit22:00、daily_token_report23: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_quarterly1/4/7/10 月 1 日 21:00
每半年 ppt_auto_generation_half_yearly1/7 月 1 日 21:10
每 1 年 ppt_auto_generation_annual1 月 1 日 21:20
"""
import asyncio
import logging
@@ -182,8 +185,43 @@ def _register_schedules():
logger.info("📅 每日 09:05roi_monthly_report月初第 1 日才送)")
# PPT 自動簡報補齊(先產出定義中的報表,再交給 22:00 vision audit
schedule.every().day.at("20:30").do(run_ppt_auto_generation_task)
logger.info("📅 每日 20:30ppt_auto_generation(補齊 PPT 定義報表")
schedule.every().day.at("20:30").do(lambda: run_ppt_auto_generation_task("daily"))
logger.info("📅 每日 20:30ppt_auto_generation_daily日報")
schedule.every().monday.at("20:40").do(lambda: run_ppt_auto_generation_task("weekly"))
logger.info("📅 每週一 20:40ppt_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:50ppt_auto_generation_monthly月報與管理型簡報")
schedule.every().day.at("21:00").do(_ppt_quarterly_gate)
logger.info("📅 每季首月1日 21:00ppt_auto_generation_quarterly季報")
schedule.every().day.at("21:10").do(_ppt_half_yearly_gate)
logger.info("📅 每半年首月1日 21:10ppt_auto_generation_half_yearly半年報")
schedule.every().day.at("21:20").do(_ppt_annual_gate)
logger.info("📅 每年1月1日 21:20ppt_auto_generation_annual年報")
# Phase 26: PPT 視覺審核(每日 22:00 掃當天新生 .pptx有 issues 才推 Telegram
schedule.every().day.at("22:00").do(run_ppt_vision_audit)

View File

@@ -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(

View File

@@ -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", [])],
}

View File

@@ -91,10 +91,13 @@
{% for item in auto_generation_items %}
<div class="ppt-mini">
<span class="ppt-label">{{ item.label }}</span>
<strong class="{% if item.ready %}status-good{% else %}status-warn{% endif %}">
{{ '已產生' if item.ready else '待補齊' }}
<strong class="{% if item.ready %}status-good{% elif item.has_other_versions %}status-blue{% else %}status-warn{% endif %}">
{% if item.ready %}目標已產生{% elif item.has_other_versions %}有其他版本{% else %}待補齊{% endif %}
</strong>
<small class="text-muted">{{ item.count }} 筆{% if item.sources %} · {{ item.sources|join(' + ') }}{% endif %}</small>
<small class="text-muted">
{{ item.target_label or '最新資料' }} · {{ item.count }} 筆{% if item.sources %} · {{ item.sources|join(' + ') }}{% endif %}
{% if item.latest_generated_at %}<br>最近 {{ item.latest_generated_at }}{% endif %}
</small>
</div>
{% endfor %}
</div>

View File

@@ -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,
}]

View File

@@ -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;
}