V10.607 建立外部市場來源正規化層
All checks were successful
CD Pipeline / deploy (push) Successful in 1m4s

This commit is contained in:
OoO
2026-06-15 16:19:03 +08:00
parent 01cc027622
commit 9260cc1740
13 changed files with 896 additions and 3 deletions

View File

@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ========================================== # ==========================================
# 系統版本與路徑 # 系統版本與路徑
# ========================================== # ==========================================
SYSTEM_VERSION = "V10.606" SYSTEM_VERSION = "V10.607"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示 public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -0,0 +1,107 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""外部市場來源與報價的正規化 ORM models。"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import (
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from database.models import Base
TAIPEI_TZ = timezone(timedelta(hours=8))
def taipei_now():
return datetime.now(TAIPEI_TZ).replace(tzinfo=None)
class ExternalMarketSource(Base):
"""外部市場資料來源,例如 MOMO 參考價、蝦皮 API、酷澎 CSV。"""
__tablename__ = "external_market_sources"
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(80), unique=True, nullable=False, index=True)
display_name = Column(String(160), nullable=False)
platform_code = Column(String(80), nullable=False, index=True)
source_kind = Column(String(60), nullable=False, index=True)
status = Column(String(40), default="paused", nullable=False, index=True)
enabled = Column(Boolean, default=False, nullable=False)
write_enabled = Column(Boolean, default=False, nullable=False)
allowed_input_methods_json = Column(Text)
quality_policy_json = Column(Text)
plain_note = Column(Text)
created_at = Column(DateTime, default=taipei_now, nullable=False)
updated_at = Column(DateTime, default=taipei_now, onupdate=taipei_now, nullable=False)
offers = relationship("ExternalOffer", back_populates="source")
__table_args__ = (
Index("idx_external_market_sources_status", "status", "enabled"),
)
class ExternalOffer(Base):
"""正規化後的外部商品報價。"""
__tablename__ = "external_offers"
id = Column(Integer, primary_key=True, autoincrement=True)
source_code = Column(String(80), ForeignKey("external_market_sources.code"), nullable=False, index=True)
platform_code = Column(String(80), nullable=False, index=True)
source_product_id = Column(String(220), nullable=False, index=True)
source_offer_key = Column(String(260), nullable=False)
title = Column(Text, nullable=False)
brand = Column(String(180), index=True)
category_text = Column(String(320), index=True)
product_url = Column(Text)
image_url = Column(Text)
price = Column(Float)
original_price = Column(Float)
currency = Column(String(12), default="TWD", nullable=False)
stock_status = Column(String(80), index=True)
sold_count = Column(Integer)
rating = Column(Float)
review_count = Column(Integer)
observed_at = Column(DateTime, default=taipei_now, nullable=False, index=True)
expires_at = Column(DateTime, index=True)
ingestion_method = Column(String(60), nullable=False, index=True)
connector_key = Column(String(120), index=True)
pchome_product_id = Column(String(120), index=True)
momo_sku = Column(String(80), index=True)
match_status = Column(String(40), default="unmatched", nullable=False, index=True)
quality_score = Column(Float, default=0.0, nullable=False)
data_quality_status = Column(String(40), default="needs_review", nullable=False, index=True)
quality_notes_json = Column(Text)
raw_payload_json = Column(Text)
created_at = Column(DateTime, default=taipei_now, nullable=False)
updated_at = Column(DateTime, default=taipei_now, onupdate=taipei_now, nullable=False)
source = relationship("ExternalMarketSource", back_populates="offers")
__table_args__ = (
UniqueConstraint(
"source_code",
"source_product_id",
"observed_at",
"ingestion_method",
name="uq_external_offer_source_product_observed",
),
Index("idx_external_offers_source_seen", "source_code", "observed_at"),
Index("idx_external_offers_platform_product", "platform_code", "source_product_id"),
Index("idx_external_offers_pchome_product", "pchome_product_id", "source_code"),
Index("idx_external_offers_match_quality", "match_status", "data_quality_status", "quality_score"),
)

View File

@@ -48,6 +48,7 @@ from .market_intel_models import ( # noqa: F401 - ADR-035 market_* 表
MarketCrawlerRun, MarketCrawlerRun,
MarketAlertReviewQueue, MarketAlertReviewQueue,
) )
from .external_market_models import ExternalMarketSource, ExternalOffer # noqa: F401 - 外部市場正規化表
# 🚩 導入優化後的日誌管理模組 # 🚩 導入優化後的日誌管理模組
from utils.logger_manager import SystemLogger from utils.logger_manager import SystemLogger

View File

@@ -1,8 +1,8 @@
# PChome 業績成長自動化作戰系統 — AI 競價情報模組 Single Source of Truth # PChome 業績成長自動化作戰系統 — AI 競價情報模組 Single Source of Truth
> **最後更新**: 2026-06-15 (台北時間) > **最後更新**: 2026-06-15 (台北時間)
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地LLM 路由紅線升級為 Ollama-first 三主機級聯PChome 後台業績匯入韌性已補強產品定位正名為「PChome 業績成長自動化作戰系統」 > **狀態**: 🟢 四 AI Agent 自動化閉環已落地LLM 路由紅線升級為 Ollama-first 三主機級聯PChome 後台業績匯入韌性已補強產品定位正名為「PChome 業績成長自動化作戰系統」;外部市場來源正規化層已建立
> **適用版本**: V10.606 > **適用版本**: V10.607
--- ---
@@ -53,6 +53,7 @@
- `services/pchome_revenue_growth_service.py` 是第一版只讀作戰清單:讀 PChome 後台業績與已驗證 MOMO 外部價格參考,輸出 `/api/ai/pchome-growth/opportunities`。此服務不呼叫 LLM、不抓外站、不寫 DB。 - `services/pchome_revenue_growth_service.py` 是第一版只讀作戰清單:讀 PChome 後台業績與已驗證 MOMO 外部價格參考,輸出 `/api/ai/pchome-growth/opportunities`。此服務不呼叫 LLM、不抓外站、不寫 DB。
- 2026-06-15 只讀盤點確認:`daily_sales_snapshot."商品ID"``competitor_prices.competitor_product_id` 在正式資料中直接重疊為 0。因此第一版作戰清單不得硬接兩邊 ID若沒有可驗證對應只能輸出「先補商品對應」任務。 - 2026-06-15 只讀盤點確認:`daily_sales_snapshot."商品ID"``competitor_prices.competitor_product_id` 在正式資料中直接重疊為 0。因此第一版作戰清單不得硬接兩邊 ID若沒有可驗證對應只能輸出「先補商品對應」任務。
- 蝦皮與酷澎暫停接入,不進作戰清單、不發告警;後續只可透過 official API / provider API / manual CSV 進 `external_offers` 類正規化層,並清楚標示資料品質。 - 蝦皮與酷澎暫停接入,不進作戰清單、不發告警;後續只可透過 official API / provider API / manual CSV 進 `external_offers` 類正規化層,並清楚標示資料品質。
- V10.607 新增 `external_market_sources` / `external_offers` 正規化層與 `/api/ai/pchome-growth/source-contract` 只讀 API。MOMO 先以既有比價快取橋接進來源狀態;蝦皮與酷澎只保留 official API、provider API、manual CSV contract預設暫停且不進告警。
## 零之一、12 Agent 決策信封2026-05-24 ## 零之一、12 Agent 決策信封2026-05-24

View File

@@ -185,3 +185,10 @@
- 更新 SOT / memory / TODO。 - 更新 SOT / memory / TODO。
- 推 Gitea正式部署確認 `/health` 版本。 - 推 Gitea正式部署確認 `/health` 版本。
- 記錄未完成與下一輪入口。 - 記錄未完成與下一輪入口。
## 10. 2026-06-15 V10.607 外部市場來源正規化
- 新增 `external_market_sources` / `external_offers`,作為 MOMO、未來蝦皮、未來酷澎、供應商 API 與手動 CSV 的共同資料入口。
- `/api/ai/pchome-growth/source-contract` 提供只讀來源狀態與欄位 contractUI 只顯示白話狀態,例如「正在使用」「先暫停」「可用資料」。
- MOMO 目前先橋接既有已確認同款的比價快取;蝦皮與酷澎只保留 contract預設暫停、不進告警。
- 下一步:做手動 CSV 匯入 dry-run 與外部報價品質檢查頁,讓未來無論官方 API 或 provider API 都能先經過同一套品質門檻。

View File

@@ -0,0 +1,175 @@
-- =============================================================================
-- Migration 044: external market source / offer normalization
-- MOMO PRO / PChome revenue growth automation
-- 2026-06-15 Taipei
-- =============================================================================
-- Notes:
-- Additive only. This migration does not drop, truncate, or rewrite existing
-- competitor_prices / competitor_price_history / market_* tables.
-- =============================================================================
CREATE TABLE IF NOT EXISTS external_market_sources (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(80) NOT NULL UNIQUE,
display_name VARCHAR(160) NOT NULL,
platform_code VARCHAR(80) NOT NULL,
source_kind VARCHAR(60) NOT NULL,
status VARCHAR(40) NOT NULL DEFAULT 'paused',
enabled BOOLEAN NOT NULL DEFAULT FALSE,
write_enabled BOOLEAN NOT NULL DEFAULT FALSE,
allowed_input_methods_json TEXT,
quality_policy_json TEXT,
plain_note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_external_market_sources_code
ON external_market_sources (code);
CREATE INDEX IF NOT EXISTS idx_external_market_sources_platform_code
ON external_market_sources (platform_code);
CREATE INDEX IF NOT EXISTS idx_external_market_sources_source_kind
ON external_market_sources (source_kind);
CREATE INDEX IF NOT EXISTS idx_external_market_sources_status
ON external_market_sources (status, enabled);
CREATE TABLE IF NOT EXISTS external_offers (
id BIGSERIAL PRIMARY KEY,
source_code VARCHAR(80) NOT NULL REFERENCES external_market_sources(code),
platform_code VARCHAR(80) NOT NULL,
source_product_id VARCHAR(220) NOT NULL,
source_offer_key VARCHAR(260) NOT NULL,
title TEXT NOT NULL,
brand VARCHAR(180),
category_text VARCHAR(320),
product_url TEXT,
image_url TEXT,
price DOUBLE PRECISION,
original_price DOUBLE PRECISION,
currency VARCHAR(12) NOT NULL DEFAULT 'TWD',
stock_status VARCHAR(80),
sold_count INTEGER,
rating DOUBLE PRECISION,
review_count INTEGER,
observed_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP,
ingestion_method VARCHAR(60) NOT NULL,
connector_key VARCHAR(120),
pchome_product_id VARCHAR(120),
momo_sku VARCHAR(80),
match_status VARCHAR(40) NOT NULL DEFAULT 'unmatched',
quality_score DOUBLE PRECISION NOT NULL DEFAULT 0,
data_quality_status VARCHAR(40) NOT NULL DEFAULT 'needs_review',
quality_notes_json TEXT,
raw_payload_json TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT uq_external_offer_source_product_observed
UNIQUE (source_code, source_product_id, observed_at, ingestion_method)
);
CREATE INDEX IF NOT EXISTS idx_external_offers_source_code
ON external_offers (source_code);
CREATE INDEX IF NOT EXISTS idx_external_offers_platform_code
ON external_offers (platform_code);
CREATE INDEX IF NOT EXISTS idx_external_offers_source_product_id
ON external_offers (source_product_id);
CREATE INDEX IF NOT EXISTS idx_external_offers_brand
ON external_offers (brand);
CREATE INDEX IF NOT EXISTS idx_external_offers_category_text
ON external_offers (category_text);
CREATE INDEX IF NOT EXISTS idx_external_offers_stock_status
ON external_offers (stock_status);
CREATE INDEX IF NOT EXISTS idx_external_offers_observed_at
ON external_offers (observed_at);
CREATE INDEX IF NOT EXISTS idx_external_offers_expires_at
ON external_offers (expires_at);
CREATE INDEX IF NOT EXISTS idx_external_offers_ingestion_method
ON external_offers (ingestion_method);
CREATE INDEX IF NOT EXISTS idx_external_offers_connector_key
ON external_offers (connector_key);
CREATE INDEX IF NOT EXISTS idx_external_offers_pchome_product_id
ON external_offers (pchome_product_id);
CREATE INDEX IF NOT EXISTS idx_external_offers_momo_sku
ON external_offers (momo_sku);
CREATE INDEX IF NOT EXISTS idx_external_offers_match_status
ON external_offers (match_status);
CREATE INDEX IF NOT EXISTS idx_external_offers_data_quality_status
ON external_offers (data_quality_status);
CREATE INDEX IF NOT EXISTS idx_external_offers_source_seen
ON external_offers (source_code, observed_at);
CREATE INDEX IF NOT EXISTS idx_external_offers_platform_product
ON external_offers (platform_code, source_product_id);
CREATE INDEX IF NOT EXISTS idx_external_offers_pchome_product
ON external_offers (pchome_product_id, source_code);
CREATE INDEX IF NOT EXISTS idx_external_offers_match_quality
ON external_offers (match_status, data_quality_status, quality_score);
INSERT INTO external_market_sources (
code,
display_name,
platform_code,
source_kind,
status,
enabled,
write_enabled,
allowed_input_methods_json,
quality_policy_json,
plain_note
) VALUES
(
'momo_reference',
'MOMO 外部價格參考',
'momo',
'legacy_bridge',
'active',
TRUE,
FALSE,
'["legacy_competitor_cache","manual_csv","provider_api"]',
'{"minimum_match_status":"verified","minimum_quality_score":76}',
'目前只採用已確認同款的 MOMO 參考價。'
),
(
'shopee',
'蝦皮',
'shopee',
'connector_contract',
'paused',
FALSE,
FALSE,
'["official_api","provider_api","manual_csv"]',
'{"minimum_match_status":"verified","manual_review_required":true}',
'先暫停,不進告警;未來可接官方 API、供應商資料或手動 CSV。'
),
(
'coupang',
'酷澎',
'coupang',
'connector_contract',
'paused',
FALSE,
FALSE,
'["official_api","provider_api","manual_csv"]',
'{"minimum_match_status":"verified","manual_review_required":true}',
'先暫停,不進告警;未來可接官方 API、供應商資料或手動 CSV。'
)
ON CONFLICT (code) DO UPDATE SET
display_name = EXCLUDED.display_name,
platform_code = EXCLUDED.platform_code,
source_kind = EXCLUDED.source_kind,
status = EXCLUDED.status,
enabled = EXCLUDED.enabled,
allowed_input_methods_json = EXCLUDED.allowed_input_methods_json,
quality_policy_json = EXCLUDED.quality_policy_json,
plain_note = EXCLUDED.plain_note,
updated_at = NOW();
GRANT ALL PRIVILEGES ON external_market_sources TO momo;
GRANT ALL PRIVILEGES ON external_offers TO momo;
GRANT USAGE, SELECT ON SEQUENCE external_market_sources_id_seq TO momo;
GRANT USAGE, SELECT ON SEQUENCE external_offers_id_seq TO momo;
DO $$
BEGIN
RAISE NOTICE 'Migration 044 complete: external market source and offer normalization is ready';
END $$;

View File

@@ -25,6 +25,7 @@
| `market_intel_review_post_ai_routes.py` | 市場情報 AI summary persistence / Telegram dispatch 後續只讀延伸 API掛在 `market_intel_review_bp` | `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_readiness`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_receipt`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_closeout`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_gate`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_run_package`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_run_readiness`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_run_receipt`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_closeout`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_archive`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_archive_summary` | | `market_intel_review_post_ai_routes.py` | 市場情報 AI summary persistence / Telegram dispatch 後續只讀延伸 API掛在 `market_intel_review_bp` | `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_readiness`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_receipt`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_closeout`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_gate`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_run_package`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_run_readiness`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_run_receipt`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_closeout`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_archive`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_archive_summary` |
| `market_intel_review_report_routes.py` | 市場情報 report input / report run package / report run readiness / report run receipt / report closeout / report archive / report catalog handoff 後續只讀延伸 API掛在 `market_intel_review_bp` | `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_input`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_run_package`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_run_readiness`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_run_receipt`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_closeout`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_archive`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_archive_summary`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_catalog_handoff` | | `market_intel_review_report_routes.py` | 市場情報 report input / report run package / report run readiness / report run receipt / report closeout / report archive / report catalog handoff 後續只讀延伸 API掛在 `market_intel_review_bp` | `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_input`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_run_package`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_run_readiness`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_run_receipt`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_closeout`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_archive`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_archive_summary`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_catalog_handoff` |
| `api_routes.py` | 通用任務與查詢 API | `/api/run_task`, `/api/history/*` | | `api_routes.py` | 通用任務與查詢 API | `/api/run_task`, `/api/history/*` |
| `ai_routes.py` | AI 推薦、競情儀表板與 PChome 成長作戰 API | `/ai_recommend`, `/ai_intelligence`, `/api/ai/status`, `/api/ai/icaim/dashboard`, `/api/ai/pchome-growth/opportunities`, `/api/ai/pchome-growth/source-contract` |
| `export_routes.py` | 匯出功能 | `/api/export/*` | | `export_routes.py` | 匯出功能 | `/api/export/*` |
| `import_routes.py` | 匯入功能 | `/api/import_excel`, `/api/import/monthly_summary` | | `import_routes.py` | 匯入功能 | `/api/import_excel`, `/api/import/monthly_summary` |

View File

@@ -1654,6 +1654,29 @@ def api_pchome_growth_opportunities():
}), 500 }), 500
@ai_bp.route('/api/ai/pchome-growth/source-contract')
@login_required
def api_pchome_growth_source_contract():
"""外部市場來源與報價欄位規格,只讀。"""
try:
from config import DATABASE_PATH
from services.external_market_offer_service import build_external_source_readiness
engine = _create_icaim_dashboard_engine(DATABASE_PATH)
try:
payload = build_external_source_readiness(engine)
finally:
engine.dispose()
return jsonify(payload)
except Exception as exc:
logger.error("[PChomeGrowth] 外部來源規格讀取失敗: %s", exc, exc_info=True)
return jsonify({
"success": False,
"error": "外部資料來源狀態暫時無法讀取,請稍後再試。",
}), 500
@ai_bp.route('/api/ai/icaim/dashboard') @ai_bp.route('/api/ai/icaim/dashboard')
@login_required @login_required
def api_icaim_dashboard(): def api_icaim_dashboard():

View File

@@ -0,0 +1,389 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""外部市場來源與報價的正規化服務。
第一版只做資料規格、來源狀態與只讀統計,不主動抓資料、不寫 DB。
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from sqlalchemy import inspect, text
logger = logging.getLogger(__name__)
SOURCE_CONTRACTS = [
{
"code": "momo_reference",
"display_name": "MOMO 外部價格參考",
"platform_code": "momo",
"status_code": "active",
"status_label": "正在使用",
"source_kind": "legacy_bridge",
"input_methods": ["既有比價快取", "手動 CSV", "供應商 API"],
"data_quality_label": "只採用已確認同款",
"plain_note": "目前用已確認同款的 MOMO 參考價,協助判斷 PChome 商品是否需要調整售價或曝光。",
},
{
"code": "shopee",
"display_name": "蝦皮",
"platform_code": "shopee",
"status_code": "paused",
"status_label": "先暫停",
"source_kind": "connector_contract",
"input_methods": ["官方 API", "供應商 API", "手動 CSV"],
"data_quality_label": "暫不進告警",
"plain_note": "先保留資料接口,等有穩定合法來源後再啟用,不會影響目前作戰清單。",
},
{
"code": "coupang",
"display_name": "酷澎",
"platform_code": "coupang",
"status_code": "paused",
"status_label": "先暫停",
"source_kind": "connector_contract",
"input_methods": ["官方 API", "供應商 API", "手動 CSV"],
"data_quality_label": "暫不進告警",
"plain_note": "先保留資料接口,等有穩定合法來源後再啟用,不會影響目前作戰清單。",
},
]
NORMALIZED_OFFER_FIELDS = [
{
"name": "source_code",
"label": "資料來源",
"required": True,
"plain_note": "例如 momo_reference、shopee、coupang。",
},
{
"name": "source_product_id",
"label": "外部商品 ID",
"required": True,
"plain_note": "外部平台或資料供應商給的商品編號。",
},
{
"name": "title",
"label": "商品名稱",
"required": True,
"plain_note": "用來做人工確認與名稱比對。",
},
{
"name": "price",
"label": "售價",
"required": True,
"plain_note": "只填可直接比較的成交或頁面售價。",
},
{
"name": "observed_at",
"label": "資料時間",
"required": True,
"plain_note": "這筆價格看到的時間。",
},
{
"name": "ingestion_method",
"label": "取得方式",
"required": True,
"plain_note": "official_api、provider_api、manual_csv 或 legacy_competitor_cache。",
},
{
"name": "pchome_product_id",
"label": "PChome 商品 ID",
"required": False,
"plain_note": "若已確認同款才填,未確認就留空。",
},
{
"name": "quality_score",
"label": "資料可信度",
"required": False,
"plain_note": "0 到 100低於 76 不進自動告警。",
},
]
@dataclass(frozen=True)
class ExternalOfferPayload:
source_code: str
platform_code: str
source_product_id: str
title: str
price: float | None
observed_at: str
ingestion_method: str
currency: str = "TWD"
original_price: float | None = None
product_url: str | None = None
brand: str | None = None
category_text: str | None = None
pchome_product_id: str | None = None
momo_sku: str | None = None
match_status: str = "unmatched"
quality_score: float = 0.0
data_quality_status: str = "needs_review"
quality_notes: list[str] = field(default_factory=list)
def to_record(self) -> dict[str, Any]:
return {
"source_code": self.source_code,
"platform_code": self.platform_code,
"source_product_id": self.source_product_id,
"source_offer_key": f"{self.source_code}:{self.source_product_id}",
"title": self.title,
"price": self.price,
"currency": self.currency or "TWD",
"original_price": self.original_price,
"product_url": self.product_url,
"brand": self.brand,
"category_text": self.category_text,
"observed_at": self.observed_at,
"ingestion_method": self.ingestion_method,
"pchome_product_id": self.pchome_product_id,
"momo_sku": self.momo_sku,
"match_status": self.match_status,
"quality_score": self.quality_score,
"data_quality_status": self.data_quality_status,
"quality_notes_json": json.dumps(self.quality_notes, ensure_ascii=False),
}
def _to_float(value: Any) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _load_json_list(value: Any) -> list[Any]:
if not value:
return []
if isinstance(value, list):
return value
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, list) else []
except Exception:
return []
def _has_table(conn, table_name: str) -> bool:
try:
return inspect(conn).has_table(table_name)
except Exception:
logger.warning("[ExternalMarket] table probe failed: %s", table_name, exc_info=True)
return False
def normalize_external_offer_payload(payload: dict[str, Any]) -> tuple[ExternalOfferPayload | None, list[str]]:
"""把 official API / provider API / manual CSV 的資料轉成同一份欄位。"""
errors: list[str] = []
source_code = str(payload.get("source_code") or "").strip()
platform_code = str(payload.get("platform_code") or source_code or "").strip()
source_product_id = str(payload.get("source_product_id") or "").strip()
title = str(payload.get("title") or payload.get("name") or "").strip()
ingestion_method = str(payload.get("ingestion_method") or "").strip()
observed_at = str(payload.get("observed_at") or "").strip()
price = _to_float(payload.get("price"))
required_values = {
"資料來源": source_code,
"外部商品 ID": source_product_id,
"商品名稱": title,
"售價": price,
"資料時間": observed_at,
"取得方式": ingestion_method,
}
for label, value in required_values.items():
if value is None or value == "":
errors.append(f"缺少{label}")
if price is not None and price <= 0:
errors.append("售價必須大於 0")
if observed_at:
try:
datetime.fromisoformat(observed_at.replace("Z", "+00:00"))
except ValueError:
errors.append("資料時間格式需為 ISO 格式,例如 2026-06-15T10:00:00")
if errors:
return None, errors
quality_score = _to_float(payload.get("quality_score"))
if quality_score is None:
quality_score = 0.0
quality_notes = _load_json_list(payload.get("quality_notes"))
if not quality_notes and payload.get("quality_note"):
quality_notes = [str(payload.get("quality_note"))]
record = ExternalOfferPayload(
source_code=source_code,
platform_code=platform_code,
source_product_id=source_product_id,
title=title,
price=price,
observed_at=observed_at,
ingestion_method=ingestion_method,
currency=str(payload.get("currency") or "TWD").strip() or "TWD",
original_price=_to_float(payload.get("original_price")),
product_url=payload.get("product_url"),
brand=payload.get("brand"),
category_text=payload.get("category_text"),
pchome_product_id=payload.get("pchome_product_id"),
momo_sku=payload.get("momo_sku"),
match_status=str(payload.get("match_status") or "unmatched"),
quality_score=max(0.0, min(100.0, quality_score)),
data_quality_status=str(payload.get("data_quality_status") or "needs_review"),
quality_notes=[str(item) for item in quality_notes],
)
return record, []
def _legacy_momo_reference_stats(conn) -> dict[str, Any]:
if not _has_table(conn, "competitor_prices"):
return {"usable_offer_count": 0, "last_seen_at": None}
if conn.dialect.name == "postgresql":
sql = """
SELECT COUNT(*) AS usable_offer_count, MAX(crawled_at) AS last_seen_at
FROM competitor_prices
WHERE source = 'pchome'
AND competitor_product_id IS NOT NULL
AND price IS NOT NULL
AND price > 0
AND COALESCE(match_score, 0) >= 0.76
AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
AND COALESCE(tags, '[]'::jsonb) ? 'identity_v2'
"""
else:
sql = """
SELECT COUNT(*) AS usable_offer_count, MAX(crawled_at) AS last_seen_at
FROM competitor_prices
WHERE source = 'pchome'
AND competitor_product_id IS NOT NULL
AND price IS NOT NULL
AND price > 0
AND COALESCE(match_score, 0) >= 0.76
AND COALESCE(tags, '') LIKE '%identity_v2%'
"""
row = conn.execute(text(sql)).mappings().first() or {}
return {
"usable_offer_count": int(row.get("usable_offer_count") or 0),
"last_seen_at": str(row.get("last_seen_at") or "") or None,
}
def _normalized_offer_stats(conn) -> dict[str, dict[str, Any]]:
if not all(_has_table(conn, table) for table in {"external_market_sources", "external_offers"}):
return {}
rows = conn.execute(text("""
SELECT
s.code,
s.status,
s.enabled,
COUNT(o.id) AS offer_count,
SUM(CASE
WHEN o.data_quality_status IN ('verified', 'usable', 'reviewed')
AND COALESCE(o.quality_score, 0) >= 76
THEN 1 ELSE 0 END
) AS usable_offer_count,
MAX(o.observed_at) AS last_seen_at
FROM external_market_sources s
LEFT JOIN external_offers o ON o.source_code = s.code
GROUP BY s.code, s.status, s.enabled
""")).mappings().all()
return {
str(row["code"]): {
"status": row.get("status"),
"enabled": bool(row.get("enabled")),
"offer_count": int(row.get("offer_count") or 0),
"usable_offer_count": int(row.get("usable_offer_count") or 0),
"last_seen_at": str(row.get("last_seen_at") or "") or None,
}
for row in rows
}
def build_connector_contracts() -> dict[str, Any]:
"""回傳 connector 與手動 CSV 共同遵守的欄位規格。"""
return {
"success": True,
"version": "2026-06-15",
"plain_summary": "所有外部市場資料都先轉成同一份商品報價格式,再進作戰清單。",
"sources": SOURCE_CONTRACTS,
"normalized_offer_fields": NORMALIZED_OFFER_FIELDS,
"manual_csv": {
"encoding": "utf-8-sig",
"required_headers": [
field["name"] for field in NORMALIZED_OFFER_FIELDS if field["required"]
],
"optional_headers": [
field["name"] for field in NORMALIZED_OFFER_FIELDS if not field["required"]
],
"plain_rule": "低可信度或未確認同款的資料只進待補資料清單,不自動發告警。",
},
}
def build_external_source_readiness(engine=None) -> dict[str, Any]:
"""建立畫面可用的外部資料來源狀態。"""
sources = [dict(source) for source in SOURCE_CONTRACTS]
normalized_stats: dict[str, dict[str, Any]] = {}
legacy_stats: dict[str, Any] = {"usable_offer_count": 0, "last_seen_at": None}
schema_ready = False
if engine is not None:
try:
with engine.connect() as conn:
schema_ready = all(
_has_table(conn, table)
for table in {"external_market_sources", "external_offers"}
)
normalized_stats = _normalized_offer_stats(conn)
legacy_stats = _legacy_momo_reference_stats(conn)
except Exception:
logger.warning("[ExternalMarket] source readiness failed", exc_info=True)
for source in sources:
stats = normalized_stats.get(source["code"], {})
if source["code"] == "momo_reference":
usable = max(
int(stats.get("usable_offer_count") or 0),
int(legacy_stats.get("usable_offer_count") or 0),
)
last_seen_at = stats.get("last_seen_at") or legacy_stats.get("last_seen_at")
else:
usable = int(stats.get("usable_offer_count") or 0)
last_seen_at = stats.get("last_seen_at")
source["schema_ready"] = schema_ready
source["usable_offer_count"] = usable
source["last_seen_at"] = last_seen_at
source["can_alert"] = source["status_code"] == "active" and usable > 0
if source["status_code"] == "active":
source["plain_state"] = "已接入,可進作戰清單" if usable else "已接入,等待可用資料"
else:
source["plain_state"] = "先保留接口,不進告警"
active_count = sum(1 for source in sources if source["status_code"] == "active")
paused_count = sum(1 for source in sources if source["status_code"] == "paused")
usable_count = sum(int(source["usable_offer_count"]) for source in sources)
return {
"success": True,
"schema_ready": schema_ready,
"active_count": active_count,
"paused_count": paused_count,
"usable_offer_count": usable_count,
"sources": sources,
"connector_contract": build_connector_contracts(),
"plain_summary": "MOMO 先用;蝦皮與酷澎先保留接口,暫不進告警。",
}

View File

@@ -11,6 +11,8 @@ from typing import Any
from sqlalchemy import bindparam, inspect, text from sqlalchemy import bindparam, inspect, text
from services.external_market_offer_service import build_external_source_readiness
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SYSTEM_DISPLAY_NAME = "PChome 業績成長自動化作戰系統" SYSTEM_DISPLAY_NAME = "PChome 業績成長自動化作戰系統"
@@ -404,12 +406,14 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
"""讀取 PChome 業績與已驗證外部價格,產生營運用作戰清單。""" """讀取 PChome 業績與已驗證外部價格,產生營運用作戰清單。"""
limit = max(5, min(int(limit or 20), 50)) limit = max(5, min(int(limit or 20), 50))
generated_at = datetime.now().isoformat(timespec="seconds") generated_at = datetime.now().isoformat(timespec="seconds")
source_readiness = build_external_source_readiness(engine)
source_scope = { source_scope = {
"primary_goal": "提升 PChome 業績", "primary_goal": "提升 PChome 業績",
"primary_sales_source": PRIMARY_SALES_SOURCE, "primary_sales_source": PRIMARY_SALES_SOURCE,
"active_external_sources": list(ACTIVE_EXTERNAL_SOURCES), "active_external_sources": list(ACTIVE_EXTERNAL_SOURCES),
"paused_external_sources": list(PAUSED_EXTERNAL_SOURCES), "paused_external_sources": list(PAUSED_EXTERNAL_SOURCES),
"plain_note": "蝦皮與酷澎先暫停,不進作戰清單,也不發告警。", "plain_note": "蝦皮與酷澎先暫停,不進作戰清單,也不發告警。",
"source_readiness": source_readiness,
} }
if not _table_exists(engine, "daily_sales_snapshot"): if not _table_exists(engine, "daily_sales_snapshot"):

View File

@@ -273,6 +273,56 @@
line-height: 1.55; line-height: 1.55;
} }
.growth-source-list {
display: grid;
gap: 8px;
margin-top: 10px;
}
.growth-source-chip {
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
padding: 8px 10px;
}
.growth-source-chip.is-active {
border-color: rgba(42, 134, 96, 0.24);
background: rgba(235, 248, 241, 0.78);
}
.growth-source-name {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--momo-text-strong);
font-size: 0.8rem;
font-weight: 900;
}
.growth-source-status {
border-radius: 999px;
background: rgba(42, 37, 32, 0.08);
color: var(--momo-text-muted);
font-size: 0.68rem;
font-weight: 900;
padding: 3px 7px;
white-space: nowrap;
}
.growth-source-chip.is-active .growth-source-status {
background: rgba(42, 134, 96, 0.14);
color: #1f6d4c;
}
.growth-source-detail {
margin: 5px 0 0;
color: var(--momo-text-muted);
font-size: 0.74rem;
line-height: 1.4;
}
.growth-list { .growth-list {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -506,6 +556,15 @@
</div> </div>
</div> </div>
<p class="growth-source-note" id="growthSourceNote">來源整理中...</p> <p class="growth-source-note" id="growthSourceNote">來源整理中...</p>
<div class="growth-source-list" id="growthSourceReadiness">
<div class="growth-source-chip">
<div class="growth-source-name">
<span>外部資料來源</span>
<span class="growth-source-status">整理中</span>
</div>
<p class="growth-source-detail">正在確認哪些來源可進作戰清單。</p>
</div>
</div>
</div> </div>
<div> <div>
<div class="growth-list" id="growthOpsList"> <div class="growth-list" id="growthOpsList">
@@ -741,6 +800,7 @@ async function loadGrowthOps(forceRefresh = false) {
document.getElementById('growthSourceNote').textContent = document.getElementById('growthSourceNote').textContent =
`業績來源:${scope.primary_sales_source || 'PChome 後台業績'}。外部價格先看:${active}。暫停來源:${paused}`; `業績來源:${scope.primary_sales_source || 'PChome 後台業績'}。外部價格先看:${active}。暫停來源:${paused}`;
renderGrowthSourceReadiness((scope.source_readiness || {}).sources || []);
renderGrowthOps(data.opportunities || []); renderGrowthOps(data.opportunities || []);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -751,6 +811,36 @@ async function loadGrowthOps(forceRefresh = false) {
} }
} }
function renderGrowthSourceReadiness(sources) {
const box = document.getElementById('growthSourceReadiness');
if (!box) return;
if (!sources.length) {
box.innerHTML = `<div class="growth-source-chip">
<div class="growth-source-name">
<span>外部資料來源</span>
<span class="growth-source-status">未確認</span>
</div>
<p class="growth-source-detail">目前還沒有可顯示的來源狀態。</p>
</div>`;
return;
}
box.innerHTML = sources.slice(0, 3).map((source) => {
const usable = Number(source.usable_offer_count || 0);
const detail = usable > 0
? `${source.data_quality_label || '資料可用'},目前有 ${usable.toLocaleString()} 筆可用資料。`
: `${source.plain_state || source.plain_note || '等待資料接入。'}`;
const activeClass = source.status_code === 'active' ? ' is-active' : '';
return `<div class="growth-source-chip${activeClass}">
<div class="growth-source-name">
<span>${escapeHtml(source.display_name || '未命名來源')}</span>
<span class="growth-source-status">${escapeHtml(source.status_label || '待確認')}</span>
</div>
<p class="growth-source-detail">${escapeHtml(detail)}</p>
</div>`;
}).join('');
}
function renderGrowthOps(rows) { function renderGrowthOps(rows) {
const list = document.getElementById('growthOpsList'); const list = document.getElementById('growthOpsList');
if (!rows.length) { if (!rows.length) {

View File

@@ -0,0 +1,89 @@
from sqlalchemy import create_engine, text
def test_connector_contract_keeps_shopee_and_coupang_paused_with_manual_csv_path():
from services.external_market_offer_service import build_connector_contracts
payload = build_connector_contracts()
assert payload["success"] is True
assert payload["plain_summary"] == "所有外部市場資料都先轉成同一份商品報價格式,再進作戰清單。"
sources = {source["code"]: source for source in payload["sources"]}
assert sources["momo_reference"]["status_label"] == "正在使用"
assert sources["shopee"]["status_label"] == "先暫停"
assert sources["coupang"]["status_label"] == "先暫停"
assert "手動 CSV" in sources["shopee"]["input_methods"]
assert "官方 API" in sources["coupang"]["input_methods"]
assert "price" in payload["manual_csv"]["required_headers"]
def test_normalized_offer_payload_validates_plain_required_fields():
from services.external_market_offer_service import normalize_external_offer_payload
record, errors = normalize_external_offer_payload({
"source_code": "momo_reference",
"source_product_id": "MOMO-1",
"title": "外部參考商品",
"price": "899",
"observed_at": "2026-06-15T10:00:00",
"ingestion_method": "manual_csv",
"quality_score": 82,
"quality_note": "人工確認同款",
})
assert errors == []
assert record is not None
assert record.to_record()["source_offer_key"] == "momo_reference:MOMO-1"
assert record.to_record()["data_quality_status"] == "needs_review"
missing_record, missing_errors = normalize_external_offer_payload({
"source_code": "shopee",
"price": 0,
})
assert missing_record is None
assert "缺少外部商品 ID" in missing_errors
assert "售價必須大於 0" in missing_errors
def test_external_source_readiness_uses_legacy_momo_reference_cache():
from services.external_market_offer_service import build_external_source_readiness
engine = create_engine("sqlite:///:memory:")
with engine.begin() as conn:
conn.execute(text(
"CREATE TABLE competitor_prices ("
"source TEXT, competitor_product_id TEXT, price REAL, match_score REAL, "
"tags TEXT, crawled_at TEXT, expires_at TEXT)"
))
conn.execute(text("""
INSERT INTO competitor_prices
(source, competitor_product_id, price, match_score, tags, crawled_at, expires_at)
VALUES
('pchome', 'PCH-1', 1000, 0.91, '["identity_v2"]', '2026-06-15 10:00:00', NULL),
('pchome', 'PCH-2', 500, 0.50, '["identity_v2"]', '2026-06-15 10:00:00', NULL)
"""))
payload = build_external_source_readiness(engine)
assert payload["success"] is True
assert payload["schema_ready"] is False
sources = {source["code"]: source for source in payload["sources"]}
assert sources["momo_reference"]["usable_offer_count"] == 1
assert sources["momo_reference"]["plain_state"] == "已接入,可進作戰清單"
assert sources["shopee"]["plain_state"] == "先保留接口,不進告警"
assert payload["plain_summary"] == "MOMO 先用;蝦皮與酷澎先保留接口,暫不進告警。"
def test_external_market_migration_creates_source_and_offer_tables():
from pathlib import Path
migration = Path("migrations/044_external_market_offer_normalization.sql").read_text(encoding="utf-8")
assert "CREATE TABLE IF NOT EXISTS external_market_sources" in migration
assert "CREATE TABLE IF NOT EXISTS external_offers" in migration
assert "momo_reference" in migration
assert "shopee" in migration
assert "coupang" in migration
assert "DROP " not in migration.upper()
assert "TRUNCATE " not in migration.upper()

View File

@@ -59,6 +59,11 @@ def test_pchome_growth_opportunities_use_plain_language_and_pause_shopee_coupang
assert payload["system_name"] == "PChome 業績成長自動化作戰系統" assert payload["system_name"] == "PChome 業績成長自動化作戰系統"
assert payload["source_scope"]["active_external_sources"] == ["MOMO 外部價格參考"] assert payload["source_scope"]["active_external_sources"] == ["MOMO 外部價格參考"]
assert payload["source_scope"]["paused_external_sources"] == ["蝦皮", "酷澎"] assert payload["source_scope"]["paused_external_sources"] == ["蝦皮", "酷澎"]
readiness = payload["source_scope"]["source_readiness"]
sources = {source["code"]: source for source in readiness["sources"]}
assert sources["momo_reference"]["status_label"] == "正在使用"
assert sources["shopee"]["status_label"] == "先暫停"
assert sources["coupang"]["status_label"] == "先暫停"
assert payload["stats"]["candidate_count"] == 2 assert payload["stats"]["candidate_count"] == 2
assert payload["stats"]["mapped_count"] == 1 assert payload["stats"]["mapped_count"] == 1
assert payload["stats"]["needs_mapping_count"] == 1 assert payload["stats"]["needs_mapping_count"] == 1
@@ -84,4 +89,5 @@ def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint():
assert "PChome 業績成長自動化作戰系統" in template assert "PChome 業績成長自動化作戰系統" in template
assert "/api/ai/pchome-growth/opportunities" in template assert "/api/ai/pchome-growth/opportunities" in template
assert "growthSourceReadiness" in template
assert "待補對應" in template assert "待補對應" in template