Fix daily sales snapshot date casts

This commit is contained in:
OoO
2026-05-24 17:09:22 +08:00
parent d522c07b39
commit 1eef91ec7a
6 changed files with 110 additions and 19 deletions

View File

@@ -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 # 用於模板顯示

View File

@@ -2,7 +2,7 @@
> **最後更新**: 2026-05-24 (台北時間)
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地LLM 路由紅線升級為 Ollama-first 三主機級聯Gemini 備援預設關閉
> **適用版本**: V10.435
> **適用版本**: V10.436
---

View File

@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-05-24PChome 近門檻身份回收第二輪
- **V10.436 daily_sales snapshot_date 型別修復**: `/daily_sales` 日期窗口查詢改為依 DB dialect 明確 castPostgreSQL 使用 `"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 recoveryPChome 標題缺品牌但身份詞與 50g 規格一致時可進 manual-review identityperipera 多色任選 vs 單一色號會標記 `variant_selection_review` 並留在 `true_low_confidence`,避免被誤列為可批次救回。

View File

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

View File

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

View 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