feat: schedule full ppt auto generation cadence
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
This commit is contained in:
@@ -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_* 表
|
||||
|
||||
32
database/ppt_generation_runs.py
Normal file
32
database/ppt_generation_runs.py
Normal 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})>"
|
||||
)
|
||||
@@ -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`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
26
migrations/038_create_ppt_generation_runs.sql
Normal file
26
migrations/038_create_ppt_generation_runs.sql
Normal 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);
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
23
scheduler.py
23
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(
|
||||
|
||||
@@ -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", [])],
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user