From 1eef91ec7a267c625a790b5583b211fe614a79db Mon Sep 17 00:00:00 2001 From: OoO Date: Sun, 24 May 2026 17:09:22 +0800 Subject: [PATCH] Fix daily sales snapshot date casts --- config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 2 +- docs/memory/history_logs.md | 1 + routes/daily_sales_routes.py | 51 ++++++++++----- services/chart_generator_service.py | 4 +- tests/test_daily_sales_snapshot_date_sql.py | 69 +++++++++++++++++++++ 6 files changed, 110 insertions(+), 19 deletions(-) create mode 100644 tests/test_daily_sales_snapshot_date_sql.py diff --git a/config.py b/config.py index 6f4137d..2da1938 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.435" +SYSTEM_VERSION = "V10.436" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 100121b..dee316d 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-05-24 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 備援預設關閉 -> **適用版本**: V10.435 +> **適用版本**: V10.436 --- diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 246a776..c2d2c5f 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-24:PChome 近門檻身份回收第二輪 +- **V10.436 daily_sales snapshot_date 型別修復**: `/daily_sales` 日期窗口查詢改為依 DB dialect 明確 cast:PostgreSQL 使用 `"snapshot_date"::date` 並把參數 `CAST(:start_date AS date)` / `CAST(:end_date AS date)`,SQLite 使用 `date("snapshot_date")`;metadata / fingerprint 查詢同步引用 cast 後日期,避免正式庫 `snapshot_date` 為 text 時出現 `text = date` / `text >= date` 類型錯誤。後台 `chart_generator_service.monthly_overview_chart()` 的月業績 SQL 也改為 `snapshot_date::date`,防止報表圖表因 text 欄位而空白。 - **V10.435 商品列 PChome 狀態診斷翻譯**: Dashboard 商品列的 `_build_pchome_match_status()` 補上 `makeup_finish_conflict`、`nail_tool_function_conflict`、`schick_razor_line_conflict`、`variant_selection_review` 等具體狀態文案;`_load_pchome_match_attempt_map()` 同步解析 `match_diagnostic_json` 產生 `diagnostic_reasons` / `diagnostic_reason_text`,讓 overview、覆核隊列、商品列表與 Excel 的診斷語意一致。 - **V10.434 PChome 人工覆核閉環補搜尋**: 商品看板 PChome review queue 新增「補搜尋」人工決策按鈕,對應 `needs_research` → `manual_needs_research`;`manual_rejected`、`manual_unit_price_required`、`manual_needs_research` 納入全部覆核隊列與「人工閉環」篩選,避免操作員按完否決/單位價/補搜尋後項目從列表消失、後續無法追蹤。 - **V10.433 PChome 覆核診斷標籤與 variant 回刷補強**: `competitor_intel_repository` 的 review queue / 商品看板 / Excel export 改為優先讀取 `match_diagnostic_json.reasons`,再 fallback 文字版 `error_message`;同步補 `makeup_finish_conflict`、`nail_tool_function_conflict`、`schick_razor_line_conflict`、`variant_descriptor_conflict` 等操作員可讀標籤,讓商品列表顯示「妝效質地不同、工具功能不同、除毛刀品線不同」而不是 raw machine code。matcher 另補 MUJI 精油芬香護手霜的 brandless exact recovery,PChome 標題缺品牌但身份詞與 50g 規格一致時可進 manual-review identity;peripera 多色任選 vs 單一色號會標記 `variant_selection_review` 並留在 `true_low_confidence`,避免被誤列為可批次救回。 diff --git a/routes/daily_sales_routes.py b/routes/daily_sales_routes.py index f75b1cf..b86bc3f 100644 --- a/routes/daily_sales_routes.py +++ b/routes/daily_sales_routes.py @@ -148,7 +148,7 @@ def _get_data_fingerprint(engine, table_name='daily_sales_snapshot'): try: validate_table_name(table_name) if engine.dialect.name == 'postgresql': - fingerprint_sql = f'SELECT MAX(snapshot_date)::text, COUNT(*) FROM "{table_name}"' + fingerprint_sql = f'SELECT MAX("snapshot_date"::date)::text, COUNT(*) FROM "{table_name}"' else: fingerprint_sql = f'SELECT CAST(MAX(snapshot_date) AS TEXT), COUNT(*) FROM "{table_name}"' with engine.connect() as conn: @@ -177,18 +177,32 @@ def _is_cache_valid(cache_key, engine=None, table_name='daily_sales_snapshot'): return True +def _snapshot_date_expr(engine): + if engine.dialect.name == 'postgresql': + return '"snapshot_date"::date' + return 'date("snapshot_date")' + + +def _snapshot_date_window_clause(engine): + date_expr = _snapshot_date_expr(engine) + if engine.dialect.name == 'postgresql': + return f'{date_expr} >= CAST(:start_date AS date) AND {date_expr} <= CAST(:end_date AS date)' + return f'{date_expr} >= date(:start_date) AND {date_expr} <= date(:end_date)' + + +def _date_param(value): + return pd.to_datetime(value).strftime('%Y-%m-%d') + + def _get_available_daily_dates(engine, table_name='daily_sales_snapshot'): """取得可選日期清單,避免為了 date selector 載入整張業績表。""" validate_table_name(table_name) - if engine.dialect.name == 'postgresql': - date_expr = 'snapshot_date::date' - else: - date_expr = 'date(snapshot_date)' + date_expr = _snapshot_date_expr(engine) query = text( f'SELECT DISTINCT {date_expr} AS snapshot_date ' f'FROM "{table_name}" ' - 'WHERE snapshot_date IS NOT NULL ' + 'WHERE "snapshot_date" IS NOT NULL ' 'ORDER BY snapshot_date DESC' ) with engine.connect() as conn: @@ -213,10 +227,10 @@ def _get_daily_sales_metadata(engine, table_name='daily_sales_snapshot'): ) query = text( - f'SELECT MAX(snapshot_date)::text AS max_snapshot_date, ' + f'SELECT MAX("snapshot_date"::date)::text AS max_snapshot_date, ' f'COUNT(*) AS row_count, ' - f'ARRAY_AGG(DISTINCT snapshot_date::date ORDER BY snapshot_date::date DESC) ' - f'FILTER (WHERE snapshot_date IS NOT NULL) AS available_dates ' + f'ARRAY_AGG(DISTINCT "snapshot_date"::date ORDER BY "snapshot_date"::date DESC) ' + f'FILTER (WHERE "snapshot_date" IS NOT NULL) AS available_dates ' f'FROM "{table_name}"' ) try: @@ -242,13 +256,20 @@ def _get_daily_sales_metadata(engine, table_name='daily_sales_snapshot'): def _read_daily_sales_window(engine, table_name, start_date, end_date): """只讀取畫面需要的日期窗口,降低冷 worker 首頁等待時間。""" - return safe_read_sql( - table_name, - engine=engine, - where_clause='"snapshot_date" >= :start_date AND "snapshot_date" <= :end_date', + table_name = validate_table_name(table_name) + date_expr = _snapshot_date_expr(engine) + where_clause = _snapshot_date_window_clause(engine) + query = text( + f'SELECT * FROM "{table_name}" ' + f'WHERE {where_clause} ' + f'ORDER BY {date_expr} ASC' + ) + return pd.read_sql( + query, + engine, params={ - 'start_date': start_date.strftime('%Y-%m-%d'), - 'end_date': end_date.strftime('%Y-%m-%d'), + 'start_date': _date_param(start_date), + 'end_date': _date_param(end_date), }, ) diff --git a/services/chart_generator_service.py b/services/chart_generator_service.py index c07524a..b9c2250 100644 --- a/services/chart_generator_service.py +++ b/services/chart_generator_service.py @@ -176,10 +176,10 @@ def _fetch_monthly_revenue(months: int = 6) -> List[Dict]: session = get_session() try: rows = session.execute(text(f""" - SELECT DATE_TRUNC('month', snapshot_date)::date AS mon, + SELECT DATE_TRUNC('month', snapshot_date::date)::date AS mon, SUM(COALESCE("總業績"::numeric, 0)) AS revenue FROM daily_sales_snapshot - WHERE snapshot_date >= NOW() - INTERVAL '{months} months' + WHERE snapshot_date::date >= CURRENT_DATE - INTERVAL '{months} months' GROUP BY mon ORDER BY mon """)).fetchall() return [{"month": str(r[0])[:7], "revenue": float(r[1] or 0)} for r in rows] diff --git a/tests/test_daily_sales_snapshot_date_sql.py b/tests/test_daily_sales_snapshot_date_sql.py new file mode 100644 index 0000000..65a4496 --- /dev/null +++ b/tests/test_daily_sales_snapshot_date_sql.py @@ -0,0 +1,69 @@ +from datetime import date +from pathlib import Path + +from sqlalchemy import create_engine + +from routes.daily_sales_routes import ( + _read_daily_sales_window, + _snapshot_date_expr, + _snapshot_date_window_clause, +) + + +class _FakePostgresDialect: + name = "postgresql" + + +class _FakePostgresEngine: + dialect = _FakePostgresDialect() + + +def test_daily_sales_window_uses_date_cast_for_postgres_text_snapshot_date(): + engine = _FakePostgresEngine() + + assert _snapshot_date_expr(engine) == '"snapshot_date"::date' + assert _snapshot_date_window_clause(engine) == ( + '"snapshot_date"::date >= CAST(:start_date AS date) ' + 'AND "snapshot_date"::date <= CAST(:end_date AS date)' + ) + + +def test_read_daily_sales_window_filters_text_snapshot_date_on_sqlite(): + engine = create_engine("sqlite:///:memory:") + with engine.begin() as conn: + conn.exec_driver_sql( + """ + CREATE TABLE daily_sales_snapshot ( + snapshot_date TEXT, + "商品ID" TEXT, + "總業績" TEXT + ) + """ + ) + conn.exec_driver_sql( + """ + INSERT INTO daily_sales_snapshot (snapshot_date, "商品ID", "總業績") + VALUES + ('2026-05-01', 'A', '100'), + ('2026-05-02', 'B', '200'), + ('2026-05-03', 'C', '300') + """ + ) + + df = _read_daily_sales_window( + engine, + "daily_sales_snapshot", + date(2026, 5, 2), + date(2026, 5, 3), + ) + + assert df["商品ID"].tolist() == ["B", "C"] + + +def test_chart_generator_monthly_revenue_casts_snapshot_date_before_date_math(): + source = (Path(__file__).resolve().parents[1] / "services" / "chart_generator_service.py").read_text(encoding="utf-8") + + assert "DATE_TRUNC('month', snapshot_date::date)::date" in source + assert "WHERE snapshot_date::date >= CURRENT_DATE" in source + assert "DATE_TRUNC('month', snapshot_date)::date" not in source + assert "WHERE snapshot_date >= NOW()" not in source