Fix daily sales snapshot date casts
This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **最後更新**: 2026-05-24 (台北時間)
|
||||
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 備援預設關閉
|
||||
> **適用版本**: V10.435
|
||||
> **適用版本**: V10.436
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`,避免被誤列為可批次救回。
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
69
tests/test_daily_sales_snapshot_date_sql.py
Normal file
69
tests/test_daily_sales_snapshot_date_sql.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user