diff --git a/.env.example b/.env.example index 174dd86..584d418 100644 --- a/.env.example +++ b/.env.example @@ -609,6 +609,9 @@ GOOGLE_DRIVE_LEGACY_PICKLE_FILE=/app/config/google_token.pickle # Authorized PChome sales acquisition. Providers are opt-in and never expose # credentials through readiness APIs or receipts. +# Daily report arrival deadline in Asia/Taipei. Before this hour, the most +# recent completed-day snapshot remains decision-ready while probes continue. +PCHOME_SALES_REPORT_CUTOFF_HOUR=20 PCHOME_SALES_HTTP_ENABLED=false PCHOME_SALES_HTTP_URL= PCHOME_SALES_HTTP_ALLOWED_HOSTS= diff --git a/config.py b/config.py index b3a4e85..09a7e08 100644 --- a/config.py +++ b/config.py @@ -414,7 +414,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.794" +SYSTEM_VERSION = "V10.795" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/memory/code_modularization_inventory_20260430.md b/docs/memory/code_modularization_inventory_20260430.md index 624bb1f..d626c99 100644 --- a/docs/memory/code_modularization_inventory_20260430.md +++ b/docs/memory/code_modularization_inventory_20260430.md @@ -72,6 +72,7 @@ - 2026-05-25 追記:同步背景 embedding 讀取 `host_health_probes` skip guard 後的 `services/ollama_service.py` 行數;此處只更新 inventory,不變更 Ollama 路由決策。 - 2026-05-29 追記:同步 PChome near-threshold / focused identity 回收系列後的 `services/marketplace_product_matcher.py` 行數;此處只更新 inventory,不變更拆分策略。 - 2026-07-14 追記:`services/pchome_revenue_growth_service.py` 因新增可稽核比價覆蓋契約與 active-catalog scope 增至 1,366 行;此輪先完成 P0 正確性,後續 `ARCH-P1-001` 應拆出 catalog coverage query 與 metric-contract builder,主 service 保留 orchestration。 +- 2026-07-15 追記:`services/pchome_growth_same_item_reconciliation.py` 已達 880 行;同商品 identity verifier、exact DB readback、coverage post-verifier 與 durable receipt persistence 應在 `ARCH-P1-001` 拆成獨立 policy/verifier/repository,主模組只保留 bounded orchestration。 ## 達到或超過 800 行檔案清單 @@ -156,6 +157,7 @@ | 1017 | `routes/export_routes.py` | P2 Export Blueprint | command、path policy、download 分離;string dtype Excel sanitizer 已修正 | | 989 | `routes/market_intel_routes.py` | P2 Market Intel Blueprint | page、API、MCP registration 分離 | | 966 | `services/trend_crawler.py` | P2 trend crawler | adapters、parser、persistence 分離 | +| 880 | `services/pchome_growth_same_item_reconciliation.py` | P1 same-item reconciliation | identity policy、exact DB verifier、coverage post-verifier、receipt repository 分離 | | 942 | `services/learning_pipeline.py` | P2 RAG learning | distill、promotion、persistence、telemetry 分離 | | 906 | `services/competitor_match_attempt_rescore_audit.py` | P2 rescore audit | query、classifier、projection 分離 | | 895 | `routes/market_intel_review_routes.py` | P2 review Blueprint | command、query、route glue 分離 | diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index fbef1d4..4746466 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -1339,6 +1339,11 @@ def _load_pchome_growth_command_center(session): default = { 'success': False, 'latest_sales_date': None, + 'sales_freshness': { + 'status': 'missing', + 'label': '待匯入', + 'decision_ready': False, + }, 'sales_7d': 0, 'sales_prev_7d': 0, 'sales_delta_pct': None, @@ -1781,6 +1786,7 @@ def _load_pchome_growth_command_center(session): command_center.update({ 'success': bool(payload.get('success', True)), 'latest_sales_date': stats.get('overall_latest_sales_date') or stats.get('latest_sales_date'), + 'sales_freshness': stats.get('sales_freshness') or {}, 'sales_7d': sales_7d, 'sales_prev_7d': sales_prev_7d, 'sales_delta_pct': sales_delta_value, diff --git a/scheduler.py b/scheduler.py index 9441f02..b66623c 100644 --- a/scheduler.py +++ b/scheduler.py @@ -2014,6 +2014,28 @@ def verify_import_data_sync(expected_rows: int = None, date_range: dict = None) return result +def _sales_freshness_stat_fields(result): + verifier = result.get('verifier') or {} + return { + "freshness_contract": result.get('freshness_contract') or verifier.get('contract_version'), + "freshness_status": result.get('freshness_status') or verifier.get('freshness_status'), + "expected_latest_sales_date": ( + result.get('expected_latest_sales_date') + or verifier.get('expected_latest_sales_date') + ), + "sla_lag_days": result.get('sla_lag_days', verifier.get('sla_lag_days')), + "grace_period_active": result.get( + 'grace_period_active', + verifier.get('grace_period_active'), + ), + "report_cutoff_hour": result.get( + 'report_cutoff_hour', + verifier.get('report_cutoff_hour'), + ), + "report_due_at": result.get('report_due_at') or verifier.get('report_due_at'), + } + + def run_auto_import_task(): """ 每半小時從授權來源取得當日業績,執行交易式匯入與獨立驗證。 @@ -2062,6 +2084,7 @@ def run_auto_import_task(): "safe_next_action": result.get('safe_next_action'), "next_retry_at": result.get('next_retry_at'), "notification_policy": result.get('notification_policy'), + **_sales_freshness_stat_fields(result), } notification_policy = result.get('notification_policy') or {} if notification_policy.get('send'): @@ -2108,6 +2131,7 @@ def run_auto_import_task(): "data_lag_days": result.get('data_lag_days'), "decision_ready": result.get('decision_ready'), "requires_upstream_acquisition": result.get('requires_upstream_acquisition', False), + **_sales_freshness_stat_fields(result), } # V-New: 發送 Telegram 和 Line 通知(僅當有匯入新檔案時) diff --git a/services/import_service.py b/services/import_service.py index 7e85d9d..5d85a59 100644 --- a/services/import_service.py +++ b/services/import_service.py @@ -30,6 +30,7 @@ DAILY_SALES_DETAIL_SHEET_HINTS = ("即時業績明細", "業績明細", "明細" DAILY_SALES_HEADER_SCAN_ROWS = 15 from services.google_drive_service import drive_service +from services.pchome_sales_freshness_policy import evaluate_pchome_sales_freshness from database.import_models import ImportJob, ImportConfig, Base from database.manager import ensure_metadata_initialized @@ -1213,17 +1214,19 @@ class ImportService: if not files: logger.info("沒有找到待匯入的檔案") - data_lag_days = None - latest_sales_date = None + freshness = evaluate_pchome_sales_freshness( + None, + now=datetime.now(TAIPEI_TZ), + ) # Staleness gate (critic-approved 2026-05-03) # 'move-then-success' 反模式:成功 import 後 move_file 把 Excel 搬到 # 「已匯入」資料夾 → 後續排程 list 回空 → 走此分支 silent return success # → 4/27~5/2 daily_sales_snapshot 停更 8 天無告警。補主動偵測: - # Drive 空 + DB ≥3 天無新資料時主動發催促告警(週末跨假期不誤觸)。 + # Drive 空時依台北時間到檔 SLA 判斷,避免午夜假性過期,也避免 + # cutoff 後仍把缺檔當成成功。 try: from sqlalchemy import text - from datetime import date from services.openclaw_strategist_service import _send_data_stale_alert _stale_session = Session() @@ -1235,19 +1238,15 @@ class ImportService: _stale_session.close() if last_date: - normalized_last_date = ( - last_date - if isinstance(last_date, date) - else date.fromisoformat(str(last_date)[:10]) + freshness = evaluate_pchome_sales_freshness( + last_date, + now=datetime.now(TAIPEI_TZ), ) - days_since = (datetime.now(TAIPEI_TZ).date() - normalized_last_date).days - data_lag_days = days_since - latest_sales_date = str(normalized_last_date) - if days_since >= 3 and send_stale_alert: + if not freshness["decision_ready"] and send_stale_alert: _send_data_stale_alert( report_type="upstream_drive", - last_date=str(normalized_last_date), - period=f"已停更 {days_since} 天", + last_date=str(freshness["latest_sales_date"]), + period=f"超過到檔 SLA {freshness['sla_lag_days']} 天", ) except Exception: logger.error( @@ -1259,22 +1258,30 @@ class ImportService: 'success': True, 'status': ( 'upstream_missing' - if data_lag_days is None + if freshness.get('latest_sales_date') is None else 'upstream_stale' - if data_lag_days >= 3 + if not freshness['decision_ready'] else 'no_pending_file' ), 'message': ( - f'上游沒有新檔,最新業績已落後 {data_lag_days} 天' - if data_lag_days is not None and data_lag_days >= 3 + f"上游沒有新檔,最新業績超過到檔 SLA {freshness['sla_lag_days']} 天" + if freshness.get('latest_sales_date') is not None + and not freshness['decision_ready'] else '沒有找到待匯入的檔案' ), 'file_count': 0, 'imported_count': 0, - 'latest_sales_date': latest_sales_date, - 'data_lag_days': data_lag_days, - 'decision_ready': data_lag_days is not None and data_lag_days <= 1, - 'requires_upstream_acquisition': data_lag_days is None or data_lag_days >= 3, + 'latest_sales_date': freshness.get('latest_sales_date'), + 'data_lag_days': freshness.get('data_lag_days'), + 'sla_lag_days': freshness.get('sla_lag_days'), + 'freshness_status': freshness.get('freshness_status'), + 'expected_latest_sales_date': freshness.get('expected_latest_sales_date'), + 'grace_period_active': freshness.get('grace_period_active'), + 'report_cutoff_hour': freshness.get('report_cutoff_hour'), + 'report_due_at': freshness.get('report_due_at'), + 'freshness_contract': freshness.get('contract_version'), + 'decision_ready': freshness.get('decision_ready'), + 'requires_upstream_acquisition': freshness.get('requires_upstream_acquisition'), } # 處理每個檔案 diff --git a/services/pchome_revenue_growth_service.py b/services/pchome_revenue_growth_service.py index 89e2426..722db31 100644 --- a/services/pchome_revenue_growth_service.py +++ b/services/pchome_revenue_growth_service.py @@ -6,12 +6,16 @@ from __future__ import annotations import json import logging -from datetime import date, datetime +from datetime import date, datetime, time from typing import Any from sqlalchemy import bindparam, inspect, text from services.external_market_offer_service import build_external_source_readiness +from services.pchome_sales_freshness_policy import ( + TAIPEI_TZ, + evaluate_pchome_sales_freshness, +) logger = logging.getLogger(__name__) @@ -60,45 +64,15 @@ def _source_names_by_status(source_readiness: dict[str, Any], status_code: str) ] -def _sales_freshness(latest_sales_date: Any, *, today: date | None = None) -> dict[str, Any]: - raw = str(latest_sales_date or "").strip() - if not raw: - return { - "status": "missing", - "label": "尚無業績資料", - "age_days": None, - "decision_ready": False, - "next_action": "自動取得並匯入最新 PChome 業績檔", - } - try: - parsed = date.fromisoformat(raw[:10]) - except ValueError: - return { - "status": "invalid", - "label": "業績日期格式異常", - "age_days": None, - "decision_ready": False, - "next_action": "修正業績資料日期後重新匯入", - } - - age_days = max(0, ((today or datetime.now().date()) - parsed).days) - if age_days <= 1: - status, label, ready = "fresh", "資料新鮮", True - elif age_days == 2: - status, label, ready = "warning", "資料即將過期", False - else: - status, label, ready = "critical", "資料已過期", False - return { - "status": status, - "label": label, - "age_days": age_days, - "decision_ready": ready, - "next_action": ( - "依最新業績執行今日作戰清單" - if ready - else "自動取得並匯入最新 PChome 業績檔" - ), - } +def _sales_freshness( + latest_sales_date: Any, + *, + today: date | None = None, + now: datetime | None = None, +) -> dict[str, Any]: + if now is None and today is not None: + now = TAIPEI_TZ.localize(datetime.combine(today, time(hour=23, minute=59))) + return evaluate_pchome_sales_freshness(latest_sales_date, now=now) def _table_exists(engine, table_name: str) -> bool: @@ -158,7 +132,6 @@ def _fetch_sales_rows(conn, limit: int) -> tuple[list[dict[str, Any]], str | Non dialect = conn.dialect.name sku_col = _quote_identifier(cols["sku"]) - name_col = _quote_identifier(cols["name"]) date_col = _quote_identifier(cols["date"]) revenue_expr = _numeric_expr(cols["revenue"], dialect) qty_expr = _numeric_expr(cols["qty"], dialect) if cols.get("qty") else "0" diff --git a/services/pchome_sales_acquisition_service.py b/services/pchome_sales_acquisition_service.py index 0d2d181..a1afecf 100644 --- a/services/pchome_sales_acquisition_service.py +++ b/services/pchome_sales_acquisition_service.py @@ -6,7 +6,7 @@ import json import logging import os import uuid -from datetime import date, datetime +from datetime import datetime from pathlib import Path from typing import Dict, List, Optional @@ -24,6 +24,7 @@ from services.pchome_sales_acquisition_providers import ( env_int, ) from services.pchome_sales_source_reconciliation import build_source_reconciliation +from services.pchome_sales_freshness_policy import evaluate_pchome_sales_freshness logger = logging.getLogger(__name__) @@ -50,23 +51,10 @@ class PChomeSalesAcquisitionService: except Exception: logger.warning("Unable to read PChome sales freshness", exc_info=True) - normalized = None - if latest: - try: - normalized = latest if isinstance(latest, date) else date.fromisoformat(str(latest)[:10]) - except (TypeError, ValueError): - normalized = None - lag_days = (datetime.now(TAIPEI_TZ).date() - normalized).days if normalized else None - return { - "latest_sales_date": normalized.isoformat() if normalized else None, - "data_lag_days": lag_days, - "decision_ready": lag_days is not None and lag_days <= 1, - "freshness_status": ( - "fresh" if lag_days is not None and lag_days <= 1 - else "degraded" if lag_days is not None - else "missing" - ), - } + return evaluate_pchome_sales_freshness( + latest, + now=datetime.now(TAIPEI_TZ), + ) def _latest_receipt(self) -> Optional[Dict[str, object]]: session = Session() @@ -590,6 +578,13 @@ class PChomeSalesAcquisitionService: "date_range": date_range, "latest_sales_date": verifier.get("latest_sales_date"), "data_lag_days": verifier.get("data_lag_days"), + "sla_lag_days": verifier.get("sla_lag_days"), + "freshness_status": verifier.get("freshness_status"), + "expected_latest_sales_date": verifier.get("expected_latest_sales_date"), + "grace_period_active": verifier.get("grace_period_active"), + "report_cutoff_hour": verifier.get("report_cutoff_hour"), + "report_due_at": verifier.get("report_due_at"), + "freshness_contract": verifier.get("contract_version"), "decision_ready": verifier.get("decision_ready"), "requires_upstream_acquisition": not bool(verifier.get("decision_ready")), "manual_review_required": False, diff --git a/services/pchome_sales_freshness_policy.py b/services/pchome_sales_freshness_policy.py new file mode 100644 index 0000000..88141b4 --- /dev/null +++ b/services/pchome_sales_freshness_policy.py @@ -0,0 +1,158 @@ +"""Taipei-time availability SLA for PChome daily sales reports.""" + +from __future__ import annotations + +import os +from datetime import date, datetime, time, timedelta +from typing import Any + +import pytz + + +CONTRACT_VERSION = "pchome_sales_freshness_sla_v1" +TAIPEI_TZ = pytz.timezone("Asia/Taipei") +DEFAULT_REPORT_CUTOFF_HOUR = 20 + + +def _bounded_cutoff_hour(value: Any = None) -> int: + raw = os.getenv("PCHOME_SALES_REPORT_CUTOFF_HOUR", "20") if value is None else value + try: + parsed = int(raw) + except (TypeError, ValueError): + parsed = DEFAULT_REPORT_CUTOFF_HOUR + return max(0, min(23, parsed)) + + +def _taipei_now(value: datetime | None) -> datetime: + if value is None: + return datetime.now(TAIPEI_TZ) + if value.tzinfo is None: + return TAIPEI_TZ.localize(value) + return value.astimezone(TAIPEI_TZ) + + +def _parse_sales_date(value: Any) -> date | None: + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + raw = str(value or "").strip() + if not raw: + return None + try: + return date.fromisoformat(raw[:10]) + except ValueError: + return None + + +def evaluate_pchome_sales_freshness( + latest_sales_date: Any, + *, + now: datetime | None = None, + cutoff_hour: int | None = None, +) -> dict[str, Any]: + """Evaluate freshness against the report arrival deadline, not midnight.""" + observed_at = _taipei_now(now) + bounded_cutoff = _bounded_cutoff_hour(cutoff_hour) + report_due_at = TAIPEI_TZ.localize( + datetime.combine(observed_at.date(), time(hour=bounded_cutoff)) + ) + arrival_window_open = observed_at < report_due_at + expected_latest_date = observed_at.date() - timedelta( + days=2 if arrival_window_open else 1 + ) + raw = str(latest_sales_date or "").strip() + parsed = _parse_sales_date(latest_sales_date) + + common = { + "contract_version": CONTRACT_VERSION, + "observed_at": observed_at.isoformat(), + "report_timezone": "Asia/Taipei", + "report_cutoff_hour": bounded_cutoff, + "report_due_at": report_due_at.isoformat(), + "arrival_window_open": arrival_window_open, + "expected_latest_sales_date": expected_latest_date.isoformat(), + } + if not raw: + return { + **common, + "latest_sales_date": None, + "status": "missing", + "freshness_status": "missing", + "label": "尚無業績資料", + "age_days": None, + "data_lag_days": None, + "sla_lag_days": None, + "grace_period_active": False, + "decision_ready": False, + "requires_upstream_acquisition": True, + "next_action": "自動取得並匯入最新 PChome 業績檔", + } + if parsed is None: + return { + **common, + "latest_sales_date": raw[:10], + "status": "invalid", + "freshness_status": "invalid", + "label": "業績日期格式異常", + "age_days": None, + "data_lag_days": None, + "sla_lag_days": None, + "grace_period_active": False, + "decision_ready": False, + "requires_upstream_acquisition": True, + "next_action": "修正業績資料日期後重新匯入", + } + + age_days = (observed_at.date() - parsed).days + sla_lag_days = (expected_latest_date - parsed).days + if age_days < 0: + status = "future" + label = "業績日期超前" + ready = False + elif sla_lag_days <= 0: + ready = True + if arrival_window_open and age_days == 2: + status = "grace" + label = "到檔 SLA 內" + else: + status = "fresh" + label = "資料新鮮" + elif sla_lag_days == 1: + status = "warning" + label = "資料超過到檔 SLA" + ready = False + else: + status = "critical" + label = "資料已過期" + ready = False + + grace_period_active = bool(ready and status == "grace") + return { + **common, + "latest_sales_date": parsed.isoformat(), + "status": status, + "freshness_status": status, + "label": label, + "age_days": max(0, age_days), + "data_lag_days": max(0, age_days), + "sla_lag_days": sla_lag_days, + "grace_period_active": grace_period_active, + "decision_ready": ready, + "requires_upstream_acquisition": not ready, + "next_action": ( + "持續自動探測,於到檔 SLA 前使用最近完整日資料" + if grace_period_active + else "依最新業績執行今日作戰清單" + if ready + else "自動取得並匯入最新 PChome 業績檔" + ), + } + + +__all__ = ( + "CONTRACT_VERSION", + "DEFAULT_REPORT_CUTOFF_HOUR", + "TAIPEI_TZ", + "evaluate_pchome_sales_freshness", +) diff --git a/services/pchome_sales_source_reconciliation.py b/services/pchome_sales_source_reconciliation.py index c51c98d..940020b 100644 --- a/services/pchome_sales_source_reconciliation.py +++ b/services/pchome_sales_source_reconciliation.py @@ -251,9 +251,14 @@ def build_source_reconciliation( automation_decision = "reconcile_authorized_source_configuration" safe_next_action = "reconcile_authorized_source_configuration" - lag_bucket = _lag_bucket(freshness.get("data_lag_days")) + effective_lag_days = freshness.get("sla_lag_days") + if effective_lag_days is None: + effective_lag_days = freshness.get("data_lag_days") + lag_bucket = _lag_bucket(effective_lag_days) severity = ( - "critical" + "info" + if decision_ready + else "critical" if lag_bucket in {"critical", "missing"} or not ready_states else "warning" if lag_bucket in {"warning", "stale"} @@ -261,6 +266,9 @@ def build_source_reconciliation( ) fingerprint_payload = { "lag_bucket": lag_bucket, + "decision_ready": decision_ready, + "expected_latest_sales_date": freshness.get("expected_latest_sales_date"), + "freshness_contract": freshness.get("contract_version"), "source_states": [ [item["id"], item["runtime_state"], item.get("error_kind")] for item in source_states diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index 285454b..081cf94 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -16,7 +16,10 @@
00 PChome 業績成長作戰台 - 業績日 {{ growth.latest_sales_date or '待匯入' }} + {% set freshness = growth.sales_freshness | default({}) %} + + 業績日 {{ growth.latest_sales_date or '待匯入' }} · {{ freshness.label | default('待匯入') }} +

先看業績,再決定調價、曝光與組合

diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index 684afed..e6e2424 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -406,6 +406,10 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data(): assert "高業績商品作戰清單" in dashboard assert "業績 × MOMO 價格 × 下一步" in dashboard assert "growth.mapping_rate" in dashboard + assert "growth.sales_freshness" in dashboard + assert "freshness.label" in dashboard + assert "'sales_freshness': stats.get('sales_freshness') or {}" in route_source + assert ".dashboard-section-label .meta.is-grace" in dashboard_css assert "找同款 · 決策 · 證據 · 落地 · 回讀 · 留存" in dashboard assert "growth.ai_automation_first_viewport" in dashboard assert "ai_first_viewport.title" in dashboard diff --git a/tests/test_pchome_revenue_growth_service.py b/tests/test_pchome_revenue_growth_service.py index 3e1c3c8..b495b44 100644 --- a/tests/test_pchome_revenue_growth_service.py +++ b/tests/test_pchome_revenue_growth_service.py @@ -413,6 +413,27 @@ def test_sales_freshness_blocks_decisions_after_two_days(): assert stale["decision_ready"] is False +def test_sales_freshness_uses_report_cutoff_instead_of_midnight(): + from datetime import datetime + import pytz + from services.pchome_revenue_growth_service import _sales_freshness + + timezone = pytz.timezone("Asia/Taipei") + before_cutoff = _sales_freshness( + "2026-07-13", + now=timezone.localize(datetime(2026, 7, 15, 1, 5)), + ) + after_cutoff = _sales_freshness( + "2026-07-13", + now=timezone.localize(datetime(2026, 7, 15, 20, 1)), + ) + + assert before_cutoff["status"] == "grace" + assert before_cutoff["decision_ready"] is True + assert after_cutoff["status"] == "warning" + assert after_cutoff["decision_ready"] is False + + def test_pchome_growth_route_cache_respects_shared_invalidation_epoch(monkeypatch): from routes import ai_routes as routes diff --git a/tests/test_pchome_sales_acquisition_service.py b/tests/test_pchome_sales_acquisition_service.py index 25a0b58..d8c5d63 100644 --- a/tests/test_pchome_sales_acquisition_service.py +++ b/tests/test_pchome_sales_acquisition_service.py @@ -101,6 +101,9 @@ def test_local_drop_closes_import_and_archives_only_after_verification(monkeypat assert result["source_type"] == "controlled_local_drop" assert result["total_rows"] == 1 assert result["decision_ready"] is True + assert result["freshness_contract"] == "pchome_sales_freshness_sla_v1" + assert result["sla_lag_days"] <= 0 + assert result["report_cutoff_hour"] == 20 assert result["receipt_persisted"] is True assert len(result["trace_id"]) == 32 assert len(result["span_id"]) == 16 @@ -328,6 +331,13 @@ def test_scheduler_continues_when_legacy_hitl_pause_is_set(monkeypatch): "receipt_persisted": True, "latest_sales_date": "2026-07-10", "data_lag_days": 1, + "sla_lag_days": 0, + "freshness_status": "fresh", + "expected_latest_sales_date": "2026-07-10", + "grace_period_active": False, + "report_cutoff_hour": 20, + "report_due_at": "2026-07-14T20:00:00+08:00", + "freshness_contract": "pchome_sales_freshness_sla_v1", "decision_ready": True, "safe_next_action": "scheduler_retry_authorized_sources", "next_retry_at": "2026-07-14T19:00:00+08:00", @@ -353,3 +363,6 @@ def test_scheduler_continues_when_legacy_hitl_pause_is_set(monkeypatch): assert saved["stats"]["receipt_persisted"] is True assert saved["stats"]["next_retry_at"] == "2026-07-14T19:00:00+08:00" assert saved["stats"]["notification_policy"]["send"] is False + assert saved["stats"]["freshness_contract"] == "pchome_sales_freshness_sla_v1" + assert saved["stats"]["sla_lag_days"] == 0 + assert saved["stats"]["report_cutoff_hour"] == 20 diff --git a/tests/test_pchome_sales_freshness_policy.py b/tests/test_pchome_sales_freshness_policy.py new file mode 100644 index 0000000..844fedd --- /dev/null +++ b/tests/test_pchome_sales_freshness_policy.py @@ -0,0 +1,82 @@ +from datetime import datetime + +import pytz + +from services.pchome_sales_freshness_policy import ( + CONTRACT_VERSION, + evaluate_pchome_sales_freshness, +) + + +TAIPEI_TZ = pytz.timezone("Asia/Taipei") + + +def _taipei(value: str) -> datetime: + return TAIPEI_TZ.localize(datetime.fromisoformat(value)) + + +def test_two_calendar_days_old_is_ready_before_report_cutoff(): + result = evaluate_pchome_sales_freshness( + "2026-07-13", + now=_taipei("2026-07-15T01:05:00"), + ) + + assert result["contract_version"] == CONTRACT_VERSION + assert result["data_lag_days"] == 2 + assert result["expected_latest_sales_date"] == "2026-07-13" + assert result["sla_lag_days"] == 0 + assert result["status"] == "grace" + assert result["grace_period_active"] is True + assert result["decision_ready"] is True + assert result["report_due_at"] == "2026-07-15T20:00:00+08:00" + + +def test_same_snapshot_blocks_after_report_cutoff(): + result = evaluate_pchome_sales_freshness( + "2026-07-13", + now=_taipei("2026-07-15T20:01:00"), + ) + + assert result["expected_latest_sales_date"] == "2026-07-14" + assert result["sla_lag_days"] == 1 + assert result["status"] == "warning" + assert result["grace_period_active"] is False + assert result["decision_ready"] is False + assert result["requires_upstream_acquisition"] is True + + +def test_previous_day_snapshot_is_ready_after_cutoff(): + result = evaluate_pchome_sales_freshness( + "2026-07-14", + now=_taipei("2026-07-15T20:01:00"), + ) + + assert result["sla_lag_days"] == 0 + assert result["status"] == "fresh" + assert result["decision_ready"] is True + + +def test_future_snapshot_is_rejected_even_when_it_meets_sla(): + result = evaluate_pchome_sales_freshness( + "2026-07-16", + now=_taipei("2026-07-15T10:00:00"), + ) + + assert result["status"] == "future" + assert result["decision_ready"] is False + + +def test_missing_and_invalid_snapshots_keep_public_safe_contract(): + missing = evaluate_pchome_sales_freshness( + None, + now=_taipei("2026-07-15T10:00:00"), + ) + invalid = evaluate_pchome_sales_freshness( + "not-a-date", + now=_taipei("2026-07-15T10:00:00"), + ) + + assert missing["status"] == "missing" + assert invalid["status"] == "invalid" + assert missing["decision_ready"] is False + assert invalid["decision_ready"] is False diff --git a/tests/test_pchome_sales_source_reconciliation.py b/tests/test_pchome_sales_source_reconciliation.py index 59bcc71..ea3f607 100644 --- a/tests/test_pchome_sales_source_reconciliation.py +++ b/tests/test_pchome_sales_source_reconciliation.py @@ -53,6 +53,29 @@ def test_stale_single_source_schedules_retry_and_reports_fallback_gap(): assert payload["sources"][1]["runtime_state"] == "disabled" +def test_arrival_sla_grace_closes_freshness_without_false_alert(): + payload = build_source_reconciliation( + sources=[_source("google_drive")], + provider_attempts=[{"source": "google_drive", "status": "no_pending_file"}], + prior_receipts=[], + freshness={ + "contract_version": "pchome_sales_freshness_sla_v1", + "latest_sales_date": "2026-07-13", + "data_lag_days": 2, + "sla_lag_days": 0, + "expected_latest_sales_date": "2026-07-13", + "grace_period_active": True, + "decision_ready": True, + }, + now=NOW, + ) + + assert payload["status"] == "closed" + assert payload["safe_next_action"] == "none" + assert payload["notification_policy"]["severity"] == "info" + assert payload["notification_policy"]["send"] is False + + def test_same_state_within_cooldown_suppresses_duplicate_notification(): first = build_source_reconciliation( sources=[_source("google_drive")], diff --git a/web/static/css/page-dashboard-v2.css b/web/static/css/page-dashboard-v2.css index 277035e..acd4102 100644 --- a/web/static/css/page-dashboard-v2.css +++ b/web/static/css/page-dashboard-v2.css @@ -701,6 +701,21 @@ overflow-wrap: anywhere; } + .dashboard-section-label .meta.is-fresh, + .dashboard-section-label .meta.is-grace { + color: var(--momo-success); + } + + .dashboard-section-label .meta.is-warning { + color: var(--momo-warning-text); + } + + .dashboard-section-label .meta.is-critical, + .dashboard-section-label .meta.is-future, + .dashboard-section-label .meta.is-invalid { + color: var(--momo-danger); + } + .dashboard-kpi-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr));