feat: EwoooC 初始化 — 完整專案推版至 Gitea
Some checks failed
CD Pipeline / deploy (push) Failing after 59s

- 建立 Gitea Actions CD pipeline (.gitea/workflows/cd.yaml)
- 部署模式: rsync Python 檔案至 188 → docker restart (volume mount)
- Dockerfile/requirements 變動時自動重建 Docker image
- 部署通知: Telegram (開始/成功/失敗)
- 健康檢查: https://mo.wooo.work/health (最多 5 次重試)
- 同步最新 CLAUDE.md / ADR-008 / memory (2026-04-19)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ogt
2026-04-19 01:21:13 +08:00
commit 1b4f3a7bbe
504 changed files with 387725 additions and 0 deletions

0
database/__init__.py Normal file
View File

319
database/ai_models.py Normal file
View File

@@ -0,0 +1,319 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
AI 生成歷史記錄資料庫模型
儲存 Ollama/Gemini LLM 生成的文案和推薦結果
支援多 AI 提供者和費用追蹤
"""
from sqlalchemy import Column, Integer, String, Float, DateTime, Date, Text, Boolean, ForeignKey, Index
from sqlalchemy.orm import relationship
from datetime import datetime, date
from .models import Base
class AIGenerationHistory(Base):
"""AI 生成歷史記錄表"""
__tablename__ = 'ai_generation_history'
id = Column(Integer, primary_key=True)
# 生成類型copy (文案), recommend (推薦), weather_analysis (天氣分析)
generation_type = Column(String(50), nullable=False, index=True)
# 商品相關
product_name = Column(String(255), index=True)
# 輸入參數
input_keywords = Column(Text) # JSON 格式的關鍵字列表
input_style = Column(String(50)) # 文案風格
input_trend_topic = Column(Text) # 趨勢話題(用於推薦)
# 生成結果
output_content = Column(Text, nullable=False) # 生成的內容
# 模型資訊
model_name = Column(String(100))
generation_duration = Column(Float) # 生成耗時(秒)
# AI 提供者資訊 (新增 - 支援 Ollama/Gemini 切換)
ai_provider = Column(String(20), default='ollama') # 'ollama' 或 'gemini'
input_tokens = Column(Integer, default=0) # 輸入 token 數量 (用於 Gemini 費用計算)
output_tokens = Column(Integer, default=0) # 輸出 token 數量
# 評價與狀態
rating = Column(Integer) # 用戶評分 1-5
is_favorite = Column(Boolean, default=False) # 是否收藏
is_used = Column(Boolean, default=False) # 是否已使用
notes = Column(Text) # 用戶備註
# 用戶追蹤
created_by = Column(Integer, ForeignKey('users.id'))
created_at = Column(DateTime, default=datetime.now, index=True)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# 建立索引以優化查詢
__table_args__ = (
Index('idx_ai_history_type_created', 'generation_type', 'created_at'),
Index('idx_ai_history_product', 'product_name'),
Index('idx_ai_history_favorite', 'is_favorite', 'created_at'),
)
def to_dict(self):
"""轉換為字典格式"""
import json
return {
'id': self.id,
'generation_type': self.generation_type,
'product_name': self.product_name,
'input_keywords': json.loads(self.input_keywords) if self.input_keywords else [],
'input_style': self.input_style,
'input_trend_topic': self.input_trend_topic,
'output_content': self.output_content,
'model_name': self.model_name,
'generation_duration': self.generation_duration,
'ai_provider': self.ai_provider,
'input_tokens': self.input_tokens,
'output_tokens': self.output_tokens,
'rating': self.rating,
'is_favorite': self.is_favorite,
'is_used': self.is_used,
'notes': self.notes,
'created_by': self.created_by,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
class AIUsageTracking(Base):
"""
AI 使用量追蹤表
追蹤 Gemini API 費用和所有 AI 使用統計
"""
__tablename__ = 'ai_usage_tracking'
id = Column(Integer, primary_key=True)
# AI 提供者: 'ollama', 'gemini'
provider = Column(String(20), nullable=False, index=True)
# 模型名稱: 'gemma3:4b', 'gemini-1.5-flash', 'gemini-2.5-pro'
model_name = Column(String(100), nullable=False)
# 使用類型: 'copy', 'web_search', 'product_insights', 'trend_keywords'
usage_type = Column(String(50), nullable=False)
# Token 用量
input_tokens = Column(Integer, default=0)
output_tokens = Column(Integer, default=0)
total_tokens = Column(Integer, default=0)
# 費用計算 (USD) - 主要用於 Gemini
input_cost = Column(Float, default=0.0)
output_cost = Column(Float, default=0.0)
total_cost = Column(Float, default=0.0)
# 響應時間 (秒)
duration = Column(Float)
# 請求資訊
request_date = Column(Date, nullable=False, index=True)
created_at = Column(DateTime, default=datetime.now)
created_by = Column(Integer, ForeignKey('users.id'))
# 關聯到歷史記錄 (可選)
history_id = Column(Integer, ForeignKey('ai_generation_history.id'))
__table_args__ = (
Index('idx_usage_provider_date', 'provider', 'request_date'),
Index('idx_usage_model_date', 'model_name', 'request_date'),
)
def to_dict(self):
"""轉換為字典格式"""
return {
'id': self.id,
'provider': self.provider,
'model_name': self.model_name,
'usage_type': self.usage_type,
'input_tokens': self.input_tokens,
'output_tokens': self.output_tokens,
'total_tokens': self.total_tokens,
'input_cost': self.input_cost,
'output_cost': self.output_cost,
'total_cost': self.total_cost,
'duration': self.duration,
'request_date': self.request_date.isoformat() if self.request_date else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
class AIPromptTemplate(Base):
"""AI 提示模板表 - 儲存常用的提示詞模板"""
__tablename__ = 'ai_prompt_templates'
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False, unique=True) # 模板名稱
description = Column(String(255)) # 模板描述
template_type = Column(String(50), nullable=False, index=True) # copy, recommend, analysis
system_prompt = Column(Text) # 系統提示詞
user_prompt_template = Column(Text, nullable=False) # 用戶提示詞模板
# 預設參數
default_temperature = Column(Float, default=0.7)
default_style = Column(String(50))
is_active = Column(Boolean, default=True)
is_system = Column(Boolean, default=False) # 是否為系統內建
created_by = Column(Integer, ForeignKey('users.id'))
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
def to_dict(self):
"""轉換為字典格式"""
return {
'id': self.id,
'name': self.name,
'description': self.description,
'template_type': self.template_type,
'system_prompt': self.system_prompt,
'user_prompt_template': self.user_prompt_template,
'default_temperature': self.default_temperature,
'default_style': self.default_style,
'is_active': self.is_active,
'is_system': self.is_system,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
# 預設的提示模板
DEFAULT_PROMPT_TEMPLATES = [
{
'name': '吸睛電商文案',
'description': '適合吸引眼球的促銷文案',
'template_type': 'copy',
'system_prompt': '''你是一位專業的電商銷售文案寫手,專門為台灣電商平台撰寫商品文案。
你的文案特點:
- 使用繁體中文
- 簡潔有力,通常在 100 字以內
- 善用表情符號增加吸引力
- 強調商品賣點和消費者利益
- 適時使用行動呼籲 (CTA)''',
'user_prompt_template': '''請為以下商品撰寫銷售文案:
商品名稱:{product_name}
文案風格:使用吸引眼球的標題和表情符號
{trend_context}
請生成一段吸引人的銷售文案100字以內''',
'default_temperature': 0.8,
'default_style': '吸睛',
'is_system': True,
},
{
'name': '專業產品介紹',
'description': '適合強調功效和成分的專業文案',
'template_type': 'copy',
'system_prompt': '''你是一位專業的產品行銷專家,擅長撰寫專業且有說服力的產品介紹。
你的文案特點:
- 使用繁體中文
- 強調產品的專業性和科學依據
- 使用精確的數據和專業術語
- 建立品牌信任感''',
'user_prompt_template': '''請為以下商品撰寫專業介紹:
商品名稱:{product_name}
文案風格:使用專業術語,強調成分和功效
{trend_context}
請生成一段專業的產品介紹100字以內''',
'default_temperature': 0.5,
'default_style': '專業',
'is_system': True,
},
{
'name': '限時促銷文案',
'description': '創造緊迫感的促銷文案',
'template_type': 'copy',
'system_prompt': '''你是一位擅長製造緊迫感的行銷文案專家。
你的文案特點:
- 使用繁體中文
- 善用限時、限量等字眼
- 創造錯過可惜的感覺
- 強調立即行動的好處''',
'user_prompt_template': '''請為以下商品撰寫限時促銷文案:
商品名稱:{product_name}
文案風格:使用限時優惠的語氣,創造緊迫感
{trend_context}
請生成一段有緊迫感的促銷文案100字以內''',
'default_temperature': 0.7,
'default_style': '急迫',
'is_system': True,
},
]
class AIInsight(Base):
"""
AI 洞察與知識庫表 (符合 ADR-007 雙寫規範)
Step 2 加入,供 OpenClaw 保存歷史 PPT、分析等輸出。
(embedding 欄位將在 Step 3 透過 SQL ALTER 增加,不宣告於 SQLAlchemy避免 SQLite 相容性錯誤)
"""
__tablename__ = 'ai_insights'
id = Column(Integer, primary_key=True, autoincrement=True)
insight_type = Column(String(50), nullable=False, index=True) # ppt, competitor_analysis, weekly_meta
period = Column(String(50), index=True) # 2026-04-16, 2026-W15
product_sku = Column(String(50), index=True) # 如果針對單一商品
content = Column(Text, nullable=False) # 具體輸出內容
metadata_json = Column(Text) # 附加元數據 (JSON 字串)
created_at = Column(DateTime, default=datetime.now, index=True)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
def to_dict(self):
import json
return {
'id': self.id,
'insight_type': self.insight_type,
'period': self.period,
'product_sku': self.product_sku,
'content': self.content,
'metadata': json.loads(self.metadata_json) if self.metadata_json else {},
'created_at': self.created_at.isoformat() if self.created_at else None,
}
def init_ai_tables(session):
"""
初始化 AI 相關表和預設資料
Args:
session: SQLAlchemy session
Returns:
tuple: (success: bool, message: str)
"""
try:
# 檢查是否已有預設模板
existing_count = session.query(AIPromptTemplate).filter_by(is_system=True).count()
if existing_count == 0:
# 新增預設模板
for template_data in DEFAULT_PROMPT_TEMPLATES:
template = AIPromptTemplate(**template_data)
session.add(template)
session.commit()
return True, f"AI 模板初始化完成,新增 {len(DEFAULT_PROMPT_TEMPLATES)} 個預設模板"
else:
return True, f"AI 模板已存在 ({existing_count} 個系統模板)"
except Exception as e:
session.rollback()
return False, f"AI 模板初始化失敗: {e}"

145
database/edm_dashboard.html Normal file
View File

@@ -0,0 +1,145 @@
<!DOCTYPE html>
{% macro slugify(text) -%}
{{ text|string|replace(' ', '_')|replace(':', '')|replace('!', '')|replace('?', '')|replace('/', '')|replace('&', '')|replace('(', '')|replace(')', '') }}
{%- endmacro %}
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MOMO 限時搶購監控</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background-color: #f8f9fa; }
.card { border: none; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
.badge-up { background-color: #dc3545; color: white; }
.badge-down { background-color: #198754; color: white; }
.badge-new { background-color: #0d6efd; color: white; }
.nav-pills .nav-link.active { background-color: #d63384; }
.nav-pills .nav-link { color: #666; }
.price-tag { font-weight: bold; color: #d63384; font-size: 1.1em; }
.status-badge { font-size: 0.8em; padding: 4px 8px; border-radius: 4px; }
</style>
</head>
<body>
<nav class="navbar navbar-dark bg-dark mb-4">
<div class="container">
<a class="navbar-brand" href="/">MOMO 監控系統</a>
<div class="navbar-nav">
<a class="nav-link" href="/">主看板</a>
<a class="nav-link active" href="/edm">限時搶購 (EDM)</a>
<a class="nav-link" href="/logs">系統日誌</a>
</div>
</div>
</nav>
<div class="container mb-5">
<div class="row mb-4">
<div class="col-md-8">
<h2>🔥 限時搶購監控儀表板</h2>
<p class="text-muted">
活動時間: {{ activity_time }} |
最後更新: {{ last_update }} |
商品總數: {{ total_edm_products }}
</p>
</div>
<div class="col-md-4 text-end">
<button class="btn btn-outline-primary" onclick="triggerEdmTask()">🔄 手動更新</button>
<button class="btn btn-outline-success" onclick="triggerNotification()">📢 發送通知</button>
</div>
</div>
<!-- 時段頁籤 -->
<ul class="nav nav-pills mb-3" id="pills-tab" role="tablist">
{% for slot, stats in slot_stats.items() %}
{% set slot_id = slugify(slot) %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if slot == active_tab %}active{% endif %}"
id="pills-{{ slot_id }}-tab"
data-bs-toggle="pill"
data-bs-target="#pills-{{ slot_id }}"
type="button" role="tab"
aria-controls="pills-{{ slot_id }}"
aria-selected="{{ 'true' if slot == active_tab else 'false' }}">
{{ slot }}
<span class="badge bg-light text-dark rounded-pill ms-2">{{ stats.on_shelf }}</span>
</button>
</li>
{% endfor %}
</ul>
<div class="tab-content" id="pills-tabContent">
{% for slot, stats in slot_stats.items() %}
{% set items = grouped_items.get(slot, []) %}
{% set slot_id = slugify(slot) %}
<div class="tab-pane fade {% if slot == active_tab %}show active{% endif %}"
id="pills-{{ slot_id }}" role="tabpanel" aria-labelledby="pills-{{ slot_id }}-tab">
<!-- 該時段統計 -->
<div class="alert alert-light border mb-3">
<strong>📊 時段統計:</strong>
<span class="badge bg-primary me-2">新品: {{ stats['new'] }}</span>
<span class="badge bg-danger me-2">漲價: {{ stats['up'] }}</span>
<span class="badge bg-success me-2">降價: {{ stats['down'] }}</span>
<span class="badge bg-secondary" title="今日異動">下架: {{ stats.get('delisted_last_run', 0) }}</span>
</div>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
{% for item in items %}
<div class="col">
<div class="card h-100">
<div class="card-body">
<h6 class="card-title text-truncate">
<a href="{{ item.url }}" target="_blank" class="text-decoration-none text-dark">
{{ item.name }}
</a>
</h6>
<div class="d-flex justify-content-between align-items-center mt-2">
<span class="price-tag">${{ item.price }}</span>
<div>
{% if item.status_change == 'NEW' %}
<span class="badge badge-new status-badge">NEW</span>
{% elif item.status_change == 'PRICE_DOWN' %}
<span class="badge badge-down status-badge">↘ 降價</span>
{% elif item.status_change == 'PRICE_UP' %}
<span class="badge badge-up status-badge">↗ 漲價</span>
{% elif item.status_change == 'DELISTED' %}
<span class="badge bg-secondary status-badge">下架</span>
{% endif %}
</div>
</div>
<div class="mt-2 text-muted small">
分類: <span style="color: {{ item.category_color }}">{{ item.main_category or '未分類' }}</span>
<br>
頻次: {{ item.frequency }} 次
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
function triggerEdmTask() {
if(confirm('確定要手動執行 EDM 爬蟲嗎?')) {
fetch('/api/run_edm_task', {method: 'POST'})
.then(r => r.json())
.then(data => alert(data.message))
.catch(e => alert('錯誤: ' + e));
}
}
function triggerNotification() {
if(confirm('確定要發送比價通知嗎?')) {
fetch('/api/trigger_edm_notification', {method: 'POST'})
.then(r => r.json())
.then(data => alert(data.message))
.catch(e => alert('錯誤: ' + e));
}
}
</script>
</body>
</html>

30
database/edm_models.py Normal file
View File

@@ -0,0 +1,30 @@
from sqlalchemy import Column, Integer, String, DateTime, Text
from datetime import datetime
from database.models import Base
class PromoProduct(Base):
"""
EDM (限時搶購) 商品資料表
用於儲存從 MOMO 限時搶購頁面抓取的商品資訊
"""
__tablename__ = 'promo_products'
id = Column(Integer, primary_key=True, autoincrement=True)
batch_id = Column(String(64), index=True)
crawled_at = Column(DateTime, default=datetime.now)
time_slot = Column(String(20))
activity_time_text = Column(String(100))
session_time_text = Column(String(100))
i_code = Column(String(50), index=True)
name = Column(String(255))
price = Column(Integer)
discount_text = Column(String(20))
url = Column(Text)
image_url = Column(Text)
previous_price = Column(Integer)
remain_qty = Column(Integer)
status_change = Column(String(20), default='NEW')
page_type = Column(String(50), default='edm', index=True)

115
database/import_models.py Normal file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
匯入進度追蹤模型
"""
from sqlalchemy import Column, Integer, String, DateTime, Text, Float
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime
import pytz
# 台北時區
TAIPEI_TZ = pytz.timezone('Asia/Taipei')
def taipei_now():
"""取得台北時區的當前時間(無時區資訊,用於資料庫存儲)"""
return datetime.now(TAIPEI_TZ).replace(tzinfo=None)
Base = declarative_base()
class ImportJob(Base):
"""匯入任務模型"""
__tablename__ = 'import_jobs'
id = Column(Integer, primary_key=True, autoincrement=True, comment='匯入任務 ID')
# 任務資訊
job_type = Column(String(50), nullable=False, comment='任務類型daily_sales, vendor_stockout')
status = Column(String(20), nullable=False, default='pending', comment='狀態pending, downloading, importing, completed, failed')
# Google Drive 檔案資訊
drive_file_id = Column(String(200), comment='Google Drive 檔案 ID')
drive_file_name = Column(String(500), comment='Google Drive 檔案名稱')
drive_file_size = Column(Integer, comment='檔案大小bytes')
# 本地檔案資訊
local_file_path = Column(String(500), comment='本地檔案路徑')
# 進度資訊
progress_percent = Column(Float, default=0.0, comment='進度百分比 (0-100)')
current_step = Column(String(200), comment='當前步驟描述')
total_rows = Column(Integer, comment='總行數')
processed_rows = Column(Integer, default=0, comment='已處理行數')
success_rows = Column(Integer, default=0, comment='成功匯入行數')
error_rows = Column(Integer, default=0, comment='錯誤行數')
# 時間記錄 (2026-01-30 修正:使用台北時區)
created_at = Column(DateTime, default=taipei_now, comment='建立時間')
started_at = Column(DateTime, comment='開始時間')
completed_at = Column(DateTime, comment='完成時間')
# 結果資訊
error_message = Column(Text, comment='錯誤訊息')
import_summary = Column(Text, comment='匯入摘要JSON 格式)')
def __repr__(self):
return f"<ImportJob(id={self.id}, type={self.job_type}, status={self.status}, progress={self.progress_percent}%)>"
def to_dict(self):
"""轉換為字典格式"""
return {
'id': self.id,
'job_type': self.job_type,
'status': self.status,
'drive_file_id': self.drive_file_id,
'drive_file_name': self.drive_file_name,
'drive_file_size': self.drive_file_size,
'local_file_path': self.local_file_path,
'progress_percent': self.progress_percent,
'current_step': self.current_step,
'total_rows': self.total_rows,
'processed_rows': self.processed_rows,
'success_rows': self.success_rows,
'error_rows': self.error_rows,
'created_at': self.created_at.isoformat() if self.created_at else None,
'started_at': self.started_at.isoformat() if self.started_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
'error_message': self.error_message,
'import_summary': self.import_summary,
}
class ImportConfig(Base):
"""匯入配置模型"""
__tablename__ = 'import_config'
id = Column(Integer, primary_key=True, autoincrement=True)
# 配置項目
config_key = Column(String(100), unique=True, nullable=False, comment='配置鍵')
config_value = Column(Text, comment='配置值')
config_type = Column(String(50), comment='配置類型string, int, bool, json')
description = Column(String(500), comment='配置說明')
# 時間記錄 (2026-01-30 修正:使用台北時區)
created_at = Column(DateTime, default=taipei_now, comment='建立時間')
updated_at = Column(DateTime, default=taipei_now, onupdate=taipei_now, comment='更新時間')
def __repr__(self):
return f"<ImportConfig(key={self.config_key}, value={self.config_value})>"
def to_dict(self):
"""轉換為字典格式"""
return {
'id': self.id,
'config_key': self.config_key,
'config_value': self.config_value,
'config_type': self.config_type,
'description': self.description,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}

357
database/manager.py Normal file
View File

@@ -0,0 +1,357 @@
import os
import re
from sqlalchemy import create_engine, desc, select, text, literal
from sqlalchemy.orm import sessionmaker
from datetime import datetime
from .models import Base, Category, Product, PriceRecord, MonthlySummaryAnalysis
from .user_models import User, LoginHistory # noqa: F401 - 必須在 trend_models 之前導入,解決 ForeignKey 依賴問題
from .edm_models import PromoProduct # V-Fix: 確保 EDM 模型被註冊,以便自動建表
from .trend_models import TrendRecord, TrendKeyword, TrendAnalysis, WebSearchCache, TelegramUser # noqa: F401 - 趨勢資料表
from .ai_models import AIGenerationHistory, AIInsight, AIUsageTracking, AIPromptTemplate # AI 記憶體與洞察模型
# 🚩 導入優化後的日誌管理模組
from services.logger_manager import SystemLogger
# 初始化資料庫模組專用 Logger
sys_log = SystemLogger("Database").get_logger()
def sanitize_timestamp(timestamp_str):
"""
驗證並清理時間戳字串,防止 SQL Injection
Args:
timestamp_str: 時間戳字串格式YYYY-MM-DD HH:MM:SS
Returns:
str: 驗證通過的時間戳
Raises:
ValueError: 格式不正確
"""
# 只允許標準時間格式YYYY-MM-DD HH:MM:SS
if not re.match(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$', timestamp_str):
raise ValueError(f"時間戳格式不正確: {timestamp_str}")
return timestamp_str
class DatabaseManager:
def __init__(self, db_path=None):
"""
初始化資料庫連線。
優先使用 PostgreSQL (透過 config.py 設定),否則回退到 SQLite。
"""
# V-Fix (2026-01-23): 優先使用 config.py 的資料庫設定
from config import DATABASE_PATH, DATABASE_TYPE
if DATABASE_TYPE == 'postgresql':
# PostgreSQL 模式 - 使用 config.py 的連線字串
# 連線池配置以提升穩定性
self.engine = create_engine(
DATABASE_PATH,
echo=False,
pool_pre_ping=True, # 自動檢測斷線連線
pool_size=5, # 連線池大小
max_overflow=10, # 額外連線數
pool_recycle=1800, # 30分鐘回收連線
pool_timeout=30, # 獲取連線超時
connect_args={
'connect_timeout': 10, # 連線超時 10 秒
'options': '-c statement_timeout=60000' # SQL 超時 60 秒
}
)
self.Session = sessionmaker(bind=self.engine)
sys_log.info(f"[Database] ✅ 使用 PostgreSQL 資料庫 (連線池已優化)")
else:
# SQLite 模式 - 向後相容
if db_path is None:
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
db_path = os.path.join(base_dir, 'data', 'momo_database.db')
os.makedirs(os.path.dirname(db_path), exist_ok=True)
self.engine = create_engine(f'sqlite:///{db_path}', echo=False)
Base.metadata.create_all(self.engine)
self.Session = sessionmaker(bind=self.engine)
self._check_and_fix_schema()
sys_log.info(f"[Database] 使用 SQLite 資料庫: {db_path}")
def _check_and_fix_schema(self):
"""自動檢查並修復資料庫結構 (僅限 SQLite)"""
# 此方法使用 SQLite PRAGMA 語法,不適用於 PostgreSQL
from config import DATABASE_TYPE
if DATABASE_TYPE == 'postgresql':
return # PostgreSQL 不需要此修復邏輯
session = self.get_session()
try:
# 1. 檢查 promo_products 是否缺少 url 欄位
result = session.execute(text("PRAGMA table_info(promo_products)")).fetchall()
if result:
columns = [row[1] for row in result]
if 'url' not in columns:
sys_log.warning("⚠️ 偵測到 promo_products 表缺少 url 欄位,正在自動修復...")
session.execute(text("ALTER TABLE promo_products ADD COLUMN url TEXT"))
# 2. 檢查 products 表是否缺少 status 與 updated_at 欄位
result_prod = session.execute(text("PRAGMA table_info(products)")).fetchall()
if result_prod:
prod_columns = [row[1] for row in result_prod]
if 'status' not in prod_columns:
sys_log.warning("⚠️ 偵測到 products 表缺少 status 欄位,正在自動修復...")
session.execute(text("ALTER TABLE products ADD COLUMN status TEXT DEFAULT 'ACTIVE'"))
if 'updated_at' not in prod_columns:
sys_log.warning("⚠️ 偵測到 products 表缺少 updated_at 欄位,正在自動修復...")
now_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 使用清理函數防止 SQL Injection
safe_timestamp = sanitize_timestamp(now_str)
session.execute(text(f"ALTER TABLE products ADD COLUMN updated_at TIMESTAMP DEFAULT '{safe_timestamp}'"))
session.commit()
except Exception as e:
sys_log.error(f"❌ 資料庫結構檢查失敗: {e}")
finally:
session.close()
def get_session(self):
"""
提供外部調用的 Session 實例。
"""
return self.Session()
def update_data(self, product_list):
"""
🚀 批次異動偵測與日誌分析邏輯:
1. 利用記憶體快照進行三重比對,並記錄詳細數據流向。
2. 針對大量數據新增 (異常監控) 觸發警告等級日誌。
3. 維持每 100 筆分段 Commit 的效能優勢。
"""
session = self.get_session()
count_added = 0
count_skipped = 0
try:
# 1. 建立對比快取:一次抓出所有商品最後一筆紀錄
latest_prices = session.query(
Product.i_code, Product.name, PriceRecord.price
).join(PriceRecord).order_by(PriceRecord.timestamp.desc()).all()
db_cache = {row[0]: (row[1], row[2]) for row in latest_prices}
sys_log.info(f"💾 開始數據比對:目前資料庫已知商品數 {len(db_cache)}")
for item in product_list:
i_code = item['i_code']
current_name = item['name']
current_price = item['price']
# 2. 三重比對邏輯:偵測商品是否真的需要更新
if i_code in db_cache:
last_name, last_price = db_cache[i_code]
if last_name == current_name and last_price == current_price:
count_skipped += 1
continue
# 3. 處理分類項目
category = session.query(Category).filter_by(name=item['category']).first()
if not category:
category = Category(name=item['category'])
session.add(category)
session.flush()
# 4. 處理商品基本資訊
product = session.query(Product).filter_by(i_code=i_code).first()
if not product:
product = Product(
i_code=i_code,
name=current_name,
url=item['url'],
category=item['category'],
category_id=category.id
)
session.add(product)
session.flush()
else:
product.name = current_name
product.category = item['category']
product.category_id = category.id
# 5. 寫入新的價格紀錄
new_price = PriceRecord(
product_id=product.id,
price=current_price,
timestamp=datetime.now()
)
session.add(new_price)
count_added += 1
if count_added % 100 == 0:
session.commit()
# 7. 最後提交並彙報日誌
session.commit()
sys_log.info(f"📊 數據同步彙報: [新增監控: {count_added}] [跳過重複: {count_skipped}]")
if count_added > 50:
sys_log.warning(f"⚠️ 偵測到異常大量新增數據 ({count_added} 筆),請確認分類 URL 或爬取範圍是否正確")
return count_added
except Exception as e:
session.rollback()
sys_log.error(f"❌ 資料寫入異常:{str(e)}")
return 0
finally:
session.close()
def get_price_analysis(self, product_id, external_session=None):
"""
🚩 整合優化:取得該商品的歷史價格波動分析。
用於 Excel 報表生成時計算漲跌數值。
"""
session = external_session if external_session else self.get_session()
try:
# 取得該商品所有的價格紀錄,按時間由新到舊排序
records = session.query(PriceRecord).filter_by(product_id=product_id)\
.order_by(PriceRecord.timestamp.desc()).all()
if not records:
return {'current': 0, '7d_diff': 0, '30d_diff': 0}
current_price = records[0].price
# 計算波動 (若數據長度不足則回傳 0)
# 註此處假設每天一筆數據records[7] 約為一週前records[30] 約為一月前
diff_7d = current_price - records[7].price if len(records) > 7 else 0
diff_30 = current_price - records[30].price if len(records) > 30 else 0
return {
'current': current_price,
'7d_diff': diff_7d,
'30d_diff': diff_30
}
except Exception as e:
sys_log.error(f"❌ 價格分析計算失敗 (ID: {product_id}): {e}")
return {'current': 0, '7d_diff': 0, '30d_diff': 0}
finally:
if not external_session:
session.close()
def get_sales_data(self, table_name='realtime_sales_monthly', start_date=None, end_date=None, months=None):
"""
從指定的銷售資料表中讀取資料
Args:
table_name: 資料表名稱 (預設: realtime_sales_monthly)
start_date: 開始日期 (格式: YYYY-MM-DD)
end_date: 結束日期 (格式: YYYY-MM-DD)
months: 查詢最近幾個月的資料
Returns:
tuple: (DataFrame, cols_map) - 資料框和欄位映射字典
"""
import pandas as pd
from datetime import datetime, timedelta
try:
# 建立日期過濾條件
date_filter = ""
if start_date and end_date:
date_filter = f" WHERE \"日期\" BETWEEN '{start_date}' AND '{end_date}'"
elif months:
# 計算 months 個月前的日期
end_dt = datetime.now()
start_dt = end_dt - timedelta(days=months * 30)
start_date_str = start_dt.strftime('%Y-%m-%d')
end_date_str = end_dt.strftime('%Y-%m-%d')
date_filter = f" WHERE \"日期\" BETWEEN '{start_date_str}' AND '{end_date_str}'"
# 執行查詢
sql = f"SELECT * FROM {table_name}{date_filter}"
df = pd.read_sql(text(sql), self.engine)
# V-Fix: 將數值欄位轉換為數字類型
numeric_columns = ['總業績', '數量', '總成本', '退貨數量', '商品單位售價',
'折價券折扣金額', '折扣金額', '滿額再折扣金額', '分期手續費']
for col in numeric_columns:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0)
# 建立欄位映射
cols = df.columns.tolist()
def find_col(keywords):
for keyword in keywords:
for col in cols:
if keyword in str(col):
return col
return None
cols_map = {
'name': find_col(['商品名稱', '品名', 'Name', 'Product']),
'pid': find_col(['商品ID', 'Product ID', 'ID', 'i_code']),
'date': find_col(['日期', '交易日期', 'Date']),
'time': find_col(['時間', 'Time']),
'amount': find_col(['總業績', '銷售金額', '業績', '金額', 'Amount', 'Sales']),
'qty': find_col(['數量', '銷售數量', '銷量', 'Qty', 'Quantity']),
'cost': find_col(['總成本', '成本', 'Cost']),
'profit': find_col(['毛利', 'Profit', 'Gross Margin']),
'category': find_col(['商品館', '館別', '分類', 'Category']),
'brand': find_col(['品牌', 'Brand']),
'vendor': find_col(['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor']),
'activity': find_col(['折扣活動名稱', '活動', 'Activity', 'Campaign']),
'payment': find_col(['付款', 'Payment', '付款方式']),
'price': find_col(['商品單位售價', '單價', 'Price']),
}
sys_log.info(f"[DB] get_sales_data 成功 | 表: {table_name} | 筆數: {len(df)}")
return df, cols_map
except Exception as e:
sys_log.error(f"[DB] get_sales_data 失敗: {e}")
return None, {}
# =============================================================================
# 全域資料庫管理器與便捷函數
# =============================================================================
# 預設使用 config.py 的 DATABASE_PATH (支援 SQLite 和 PostgreSQL)
_default_db_manager = None
def get_db_manager():
"""
取得全域 DatabaseManager 實例 (單例模式)
Returns:
DatabaseManager: 資料庫管理器實例
"""
global _default_db_manager
if _default_db_manager is None:
try:
from config import DATABASE_PATH
_default_db_manager = DatabaseManager(DATABASE_PATH)
except ImportError:
# 若 config 不可用,使用預設路徑
_default_db_manager = DatabaseManager()
return _default_db_manager
def get_session():
"""
取得資料庫 Session便捷函數
這是給其他模組使用的便捷函數,避免重複初始化 DatabaseManager。
Returns:
Session: SQLAlchemy Session 實例
Usage:
from database.manager import get_session
session = get_session()
try:
# 進行資料庫操作
session.query(...)
session.commit()
finally:
session.close()
"""
return get_db_manager().get_session()

108
database/models.py Normal file
View File

@@ -0,0 +1,108 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text, UniqueConstraint
from sqlalchemy.orm import relationship, declarative_base
from datetime import datetime
Base = declarative_base()
class Category(Base):
__tablename__ = 'categories'
id = Column(Integer, primary_key=True)
name = Column(String(50), unique=True, nullable=False)
products = relationship("Product", back_populates="category_rel")
class Product(Base):
__tablename__ = 'products'
id = Column(Integer, primary_key=True)
i_code = Column(String(50), unique=True, nullable=False, index=True)
name = Column(String(255), nullable=False)
url = Column(String(500))
image_url = Column(Text)
category = Column(String(100))
# V9.52 新增欄位
status = Column(String(20), default='ACTIVE')
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
created_at = Column(DateTime, default=datetime.now)
# 關聯設定
category_id = Column(Integer, ForeignKey('categories.id'))
category_rel = relationship("Category", back_populates="products")
prices = relationship("PriceRecord", back_populates="product", cascade="all, delete-orphan")
class PriceRecord(Base):
__tablename__ = 'price_records'
id = Column(Integer, primary_key=True)
product_id = Column(Integer, ForeignKey('products.id'), nullable=False)
price = Column(Float, nullable=False)
timestamp = Column(DateTime, default=datetime.now, index=True)
product = relationship("Product", back_populates="prices")
class MonthlySummaryAnalysis(Base):
__tablename__ = 'monthly_summary_analysis'
id = Column(Integer, primary_key=True)
year = Column(Integer, nullable=False, index=True)
month = Column(Integer, nullable=False, index=True)
department = Column(String(100))
category_3c = Column(String(100))
division = Column(String(100), index=True)
section = Column(String(100))
area_id = Column(String(50))
area_name = Column(String(100))
pm_name = Column(String(100), index=True)
brand_name = Column(String(200), index=True)
vendor_id = Column(Integer, index=True)
vendor_name = Column(String(200))
trade_type = Column(String(20))
unit_price = Column(Float)
# 指標 - 銷售額
sales_amt_curr = Column(Integer)
sales_amt_prev = Column(Integer)
sales_amt_yoa = Column(Integer)
# 指標 - 毛1額
profit_amt_curr = Column(Integer)
profit_amt_prev = Column(Integer)
profit_amt_yoa = Column(Integer)
# 指標 - 折扣金額
discount_amt_curr = Column(Integer)
discount_amt_prev = Column(Integer)
discount_amt_yoa = Column(Integer)
# 指標 - 折價券
coupon_amt_curr = Column(Integer)
coupon_amt_prev = Column(Integer)
coupon_amt_yoa = Column(Integer)
# 指標 - 其他行銷活動
other_mkt_curr = Column(Integer)
other_mkt_prev = Column(Integer)
other_mkt_yoa = Column(Integer)
# 指標 - 點我折
spot_disc_curr = Column(Integer)
spot_disc_prev = Column(Integer)
spot_disc_yoa = Column(Integer)
# 指標 - 點數折抵
point_disc_curr = Column(Integer)
point_disc_prev = Column(Integer)
point_disc_yoa = Column(Integer)
# 指標 - 銷售量
sales_vol_curr = Column(Integer)
sales_vol_prev = Column(Integer)
sales_vol_yoa = Column(Integer)
# 指標 - 轉換率與瀏覽數
conv_rate = Column(Float)
views_curr = Column(Integer)
views_prev = Column(Integer)
views_yoa = Column(Integer)
created_at = Column(DateTime, default=datetime.now)
__table_args__ = (
UniqueConstraint('year', 'month', 'department', 'category_3c', 'division', 'section', 'area_id', 'pm_name', 'brand_name', 'vendor_id', 'trade_type', name='_monthly_summary_uc'),
)

View File

@@ -0,0 +1,236 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
通知模板資料模型
用於管理 n8n 和系統通知的訊息模板
"""
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime, timezone, timedelta
Base = declarative_base()
TAIPEI_TZ = timezone(timedelta(hours=8))
class NotificationTemplate(Base):
"""通知模板"""
__tablename__ = 'notification_templates'
id = Column(Integer, primary_key=True)
code = Column(String(50), unique=True, nullable=False) # 模板代碼,如 'disk_alert'
name = Column(String(100), nullable=False) # 顯示名稱
category = Column(String(50), nullable=False) # 分類system, business, report
channel = Column(String(20), default='telegram') # 通知渠道telegram, line, both
# 模板內容
title = Column(String(200)) # 標題
body = Column(Text, nullable=False) # 訊息內容(支援變數)
emoji_prefix = Column(String(10)) # 前綴 emoji
# 狀態
is_active = Column(Boolean, default=True)
# 時間戳
created_at = Column(DateTime, default=lambda: datetime.now(TAIPEI_TZ).replace(tzinfo=None))
updated_at = Column(DateTime, onupdate=lambda: datetime.now(TAIPEI_TZ).replace(tzinfo=None))
def to_dict(self):
return {
'id': self.id,
'code': self.code,
'name': self.name,
'category': self.category,
'channel': self.channel,
'title': self.title,
'body': self.body,
'emoji_prefix': self.emoji_prefix,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
# 預設模板
DEFAULT_TEMPLATES = [
{
'code': 'disk_warning',
'name': '磁碟空間警告',
'category': 'system',
'channel': 'telegram',
'emoji_prefix': '🟠',
'title': '磁碟空間警告',
'body': '''📊 使用率: {usage_percent}%
💾 剩餘: {free_gb} GB / {total_gb} GB
🔧 正在執行自動清理...'''
},
{
'code': 'disk_critical',
'name': '磁碟空間嚴重不足',
'category': 'system',
'channel': 'telegram',
'emoji_prefix': '🔴',
'title': '磁碟空間嚴重不足',
'body': '''📊 使用率: {usage_percent}%
💾 剩餘: {free_gb} GB / {total_gb} GB
⚠️ 立即執行緊急清理!'''
},
{
'code': 'cleanup_complete',
'name': '自動清理完成',
'category': 'system',
'channel': 'telegram',
'emoji_prefix': '',
'title': '自動清理完成',
'body': '''🧹 清理結果:
{results}
📊 清理後使用率: {new_usage_percent}%'''
},
{
'code': 'ssl_warning',
'name': 'SSL 證書即將到期',
'category': 'system',
'channel': 'telegram',
'emoji_prefix': '🟡',
'title': 'SSL 證書警告',
'body': '''{issues}
💡 執行: sudo certbot renew --nginx'''
},
{
'code': 'pod_unhealthy',
'name': 'K8s Pod 異常',
'category': 'system',
'channel': 'telegram',
'emoji_prefix': '🔴',
'title': 'K8s Pod 異常',
'body': '''📍 狀態: {status}
🗄️ 資料庫: {database}
🔧 正在嘗試重啟...'''
},
{
'code': 'pod_restart_result',
'name': 'Pod 重啟結果',
'category': 'system',
'channel': 'telegram',
'emoji_prefix': '',
'title': 'Pod 重啟完成',
'body': '''{deployment} 已重啟,等待恢復中...'''
},
{
'code': 'crawler_warning',
'name': '爬蟲執行警告',
'category': 'system',
'channel': 'telegram',
'emoji_prefix': '🟠',
'title': '爬蟲監控警告',
'body': '''{issues}
🔧 正在嘗試重啟 scheduler...'''
},
{
'code': 'harbor_unhealthy',
'name': 'Harbor Registry 異常',
'category': 'system',
'channel': 'telegram',
'emoji_prefix': '🟠',
'title': 'Harbor Registry 異常',
'body': '''📍 狀態: {status}
❌ 錯誤: {error}
💡 請檢查 Harbor 服務
執行: docker restart harbor-core harbor-nginx'''
},
{
'code': 'backup_warning',
'name': '備份監控警告',
'category': 'system',
'channel': 'telegram',
'emoji_prefix': '🟠',
'title': '備份監控警告',
'body': '''{error}
💡 請檢查備份排程'''
},
{
'code': 'cicd_success',
'name': 'CI/CD 成功',
'category': 'system',
'channel': 'telegram',
'emoji_prefix': '',
'title': 'CI/CD Pipeline SUCCESS',
'body': '''📦 專案: {project}
🌿 分支: {branch}
🆔 Pipeline: #{pipeline_id}
💬 Commit: {commit_message}
👤 作者: {author}
⏱️ 耗時: {duration}
🔗 查看詳情: {url}'''
},
{
'code': 'cicd_failed',
'name': 'CI/CD 失敗',
'category': 'system',
'channel': 'telegram',
'emoji_prefix': '',
'title': 'CI/CD Pipeline FAILED',
'body': '''📦 專案: {project}
🌿 分支: {branch}
🆔 Pipeline: #{pipeline_id}
💬 Commit: {commit_message}
👤 作者: {author}
🔗 查看詳情: {url}'''
},
{
'code': 'daily_report',
'name': '每日系統狀態報告',
'category': 'report',
'channel': 'telegram',
'emoji_prefix': '📊',
'title': '每日系統狀態報告',
'body': '''📅 日期: {date}
🖥️ 應用程式: {app_status}
💾 資料備份: {backup_status}
🕷️ 爬蟲排程: {crawler_status}
📦 最新備份: {last_backup}'''
},
{
'code': 'weekly_sales',
'name': '每週業績摘要',
'category': 'business',
'channel': 'telegram',
'emoji_prefix': '📈',
'title': '每週業績摘要',
'body': '''📅 週期: {week_start} ~ {week_end}
💰 總業績: ${total_sales}
📦 訂單數: {order_count}
📈 成長率: {growth_rate}%
詳細報表請登入系統查看'''
},
{
'code': 'monthly_reminder',
'name': '月初作業提醒',
'category': 'business',
'channel': 'telegram',
'emoji_prefix': '📅',
'title': '{month}月初作業提醒',
'body': '''✅ 待辦事項:
1⃣ 匯出上月 ({prev_month}) 月結報表
2⃣ 檢查 Google Drive 自動匯入設定
3⃣ 確認爬蟲排程正常運作
4⃣ 備份資料庫
5⃣ 檢查系統日誌
💡 登入系統: https://mo.wooo.work'''
}
]

View File

@@ -0,0 +1,233 @@
"""
權限系統資料模型
================
Permission - 權限定義表
UserPermission - 用戶權限關聯表
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, UniqueConstraint
from database.models import Base
class Permission(Base):
"""權限定義表 - 系統預設權限項目"""
__tablename__ = 'permissions'
id = Column(Integer, primary_key=True)
code = Column(String(50), unique=True, nullable=False, index=True) # 權限代碼,如 'dashboard.view'
name = Column(String(100), nullable=False) # 顯示名稱,如 '查看首頁看板'
category = Column(String(50), nullable=False) # 分類,如 '首頁/看板'
description = Column(String(200)) # 詳細說明
sort_order = Column(Integer, default=0) # 排序順序
def to_dict(self):
return {
'id': self.id,
'code': self.code,
'name': self.name,
'category': self.category,
'description': self.description,
'sort_order': self.sort_order
}
class UserPermission(Base):
"""用戶權限關聯表"""
__tablename__ = 'user_permissions'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
permission_code = Column(String(50), nullable=False, index=True)
granted_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
granted_at = Column(DateTime, default=datetime.utcnow)
__table_args__ = (
UniqueConstraint('user_id', 'permission_code', name='uq_user_permission'),
)
def to_dict(self):
return {
'id': self.id,
'user_id': self.user_id,
'permission_code': self.permission_code,
'granted_by': self.granted_by,
'granted_at': self.granted_at.isoformat() if self.granted_at else None
}
# ============================================================================
# 權限定義常數
# ============================================================================
PERMISSIONS = [
# 首頁/看板
{'code': 'dashboard.view', 'name': '查看首頁看板', 'category': '首頁/看板', 'description': '訪問首頁商品看板', 'sort_order': 10},
{'code': 'dashboard.export', 'name': '匯出看板資料', 'category': '首頁/看板', 'description': '匯出商品資料 Excel', 'sort_order': 11},
# 報表
{'code': 'report.daily_sales.view', 'name': '查看每日銷售', 'category': '報表', 'description': '訪問每日銷售頁面', 'sort_order': 20},
{'code': 'report.daily_sales.export', 'name': '匯出每日銷售', 'category': '報表', 'description': '匯出每日銷售 Excel', 'sort_order': 21},
{'code': 'report.monthly_summary.view', 'name': '查看月度總結', 'category': '報表', 'description': '訪問月度總結分析頁面', 'sort_order': 22},
{'code': 'report.monthly_summary.import', 'name': '匯入月度資料', 'category': '報表', 'description': '匯入月度總結 Excel', 'sort_order': 23},
{'code': 'report.sales_analysis.view', 'name': '查看銷售分析', 'category': '報表', 'description': '訪問銷售分析頁面', 'sort_order': 24},
{'code': 'report.growth_analysis.view', 'name': '查看成長分析', 'category': '報表', 'description': '訪問成長分析頁面', 'sort_order': 25},
{'code': 'report.abc_analysis.view', 'name': '查看 ABC 分析', 'category': '報表', 'description': '訪問 ABC 分析頁面', 'sort_order': 26},
# 活動看板
{'code': 'edm.view', 'name': '查看 EDM 看板', 'category': '活動看板', 'description': '訪問 EDM 看板頁面', 'sort_order': 30},
{'code': 'edm.trigger', 'name': '觸發 EDM 爬蟲', 'category': '活動看板', 'description': '手動觸發 EDM 爬蟲', 'sort_order': 31},
{'code': 'festival.view', 'name': '查看節慶看板', 'category': '活動看板', 'description': '訪問節慶看板頁面', 'sort_order': 32},
{'code': 'festival.trigger', 'name': '觸發節慶爬蟲', 'category': '活動看板', 'description': '手動觸發節慶爬蟲', 'sort_order': 33},
# 廠商缺貨
{'code': 'vendor.index.view', 'name': '查看廠商缺貨首頁', 'category': '廠商缺貨', 'description': '訪問廠商缺貨首頁', 'sort_order': 40},
{'code': 'vendor.import', 'name': '匯入缺貨資料', 'category': '廠商缺貨', 'description': '匯入缺貨 Excel', 'sort_order': 41},
{'code': 'vendor.list.view', 'name': '查看缺貨清單', 'category': '廠商缺貨', 'description': '訪問缺貨清單頁面', 'sort_order': 42},
{'code': 'vendor.list.edit', 'name': '編輯缺貨資料', 'category': '廠商缺貨', 'description': '編輯/刪除缺貨記錄', 'sort_order': 43},
{'code': 'vendor.management.view', 'name': '查看廠商管理', 'category': '廠商缺貨', 'description': '訪問廠商管理頁面', 'sort_order': 44},
{'code': 'vendor.management.edit', 'name': '管理廠商資料', 'category': '廠商缺貨', 'description': '新增/編輯/刪除廠商', 'sort_order': 45},
{'code': 'vendor.email.view', 'name': '查看郵件發送', 'category': '廠商缺貨', 'description': '訪問郵件發送頁面', 'sort_order': 46},
{'code': 'vendor.email.send', 'name': '發送廠商郵件', 'category': '廠商缺貨', 'description': '發送缺貨通知郵件', 'sort_order': 47},
{'code': 'vendor.history.view', 'name': '查看歷史記錄', 'category': '廠商缺貨', 'description': '訪問歷史記錄頁面', 'sort_order': 48},
# 匯入
{'code': 'import.auto.view', 'name': '查看自動匯入', 'category': '匯入', 'description': '訪問自動匯入頁面', 'sort_order': 50},
{'code': 'import.auto.manage', 'name': '管理匯入任務', 'category': '匯入', 'description': '新增/編輯/刪除匯入任務', 'sort_order': 51},
{'code': 'import.manual', 'name': '手動匯入資料', 'category': '匯入', 'description': '系統設定頁手動匯入', 'sort_order': 52},
# 系統
{'code': 'system.settings.view', 'name': '查看系統設定', 'category': '系統', 'description': '訪問系統設定頁面', 'sort_order': 60},
{'code': 'system.settings.edit', 'name': '修改系統設定', 'category': '系統', 'description': '儲存系統設定變更', 'sort_order': 61},
{'code': 'system.advanced.view', 'name': '查看進階設定', 'category': '系統', 'description': '訪問進階設定頁面', 'sort_order': 62},
{'code': 'system.advanced.edit', 'name': '修改進階設定', 'category': '系統', 'description': '分類管理等進階操作', 'sort_order': 63},
{'code': 'system.logs.view', 'name': '查看系統日誌', 'category': '系統', 'description': '訪問系統日誌頁面', 'sort_order': 64},
{'code': 'system.crawler.view', 'name': '查看爬蟲管理', 'category': '系統', 'description': '訪問爬蟲管理頁面', 'sort_order': 65},
{'code': 'system.crawler.manage', 'name': '管理爬蟲設定', 'category': '系統', 'description': '修改爬蟲設定', 'sort_order': 66},
{'code': 'system.backup', 'name': '備份資料庫', 'category': '系統', 'description': '執行資料庫備份', 'sort_order': 67},
{'code': 'system.users.view', 'name': '查看用戶管理', 'category': '系統', 'description': '訪問用戶管理頁面', 'sort_order': 68},
{'code': 'system.users.manage', 'name': '管理用戶帳號', 'category': '系統', 'description': '新增/編輯/刪除用戶', 'sort_order': 69},
# 其他
{'code': 'brand_assets.view', 'name': '查看品牌素材', 'category': '其他', 'description': '訪問品牌素材頁面', 'sort_order': 90},
]
# 所有權限代碼列表
ALL_PERMISSION_CODES = [p['code'] for p in PERMISSIONS]
# ============================================================================
# 角色預設權限模板
# ============================================================================
# admin: 全部權限
ROLE_ADMIN_PERMISSIONS = ALL_PERMISSION_CODES.copy()
# manager: 大部分權限,排除用戶管理和高危操作
ROLE_MANAGER_PERMISSIONS = [
# 首頁/看板
'dashboard.view', 'dashboard.export',
# 報表
'report.daily_sales.view', 'report.daily_sales.export',
'report.monthly_summary.view', 'report.monthly_summary.import',
'report.sales_analysis.view', 'report.growth_analysis.view', 'report.abc_analysis.view',
# 活動看板
'edm.view', 'edm.trigger', 'festival.view', 'festival.trigger',
# 廠商缺貨
'vendor.index.view', 'vendor.import', 'vendor.list.view', 'vendor.list.edit',
'vendor.management.view', 'vendor.management.edit',
'vendor.email.view', 'vendor.email.send', 'vendor.history.view',
# 匯入
'import.auto.view', 'import.auto.manage', 'import.manual',
# 系統 (有限)
'system.settings.view', 'system.settings.edit',
'system.advanced.view', # 可查看但不能編輯進階設定
'system.logs.view',
'system.crawler.view', # 可查看但不能管理爬蟲
# 其他
'brand_assets.view',
]
# user: 僅查看權限
ROLE_USER_PERMISSIONS = [
# 首頁/看板
'dashboard.view',
# 報表 (僅查看)
'report.daily_sales.view', 'report.monthly_summary.view',
'report.sales_analysis.view', 'report.growth_analysis.view', 'report.abc_analysis.view',
# 活動看板 (僅查看)
'edm.view', 'festival.view',
# 廠商缺貨 (部分查看)
'vendor.index.view', 'vendor.list.view', 'vendor.history.view',
# 其他
'brand_assets.view',
]
# 角色權限模板映射
ROLE_DEFAULT_PERMISSIONS = {
'admin': ROLE_ADMIN_PERMISSIONS,
'manager': ROLE_MANAGER_PERMISSIONS,
'user': ROLE_USER_PERMISSIONS,
}
def init_permissions(db_session):
"""初始化權限表資料
Args:
db_session: 資料庫 session
Returns:
tuple: (success, message)
"""
try:
# 檢查權限表是否已有資料
existing_count = db_session.query(Permission).count()
if existing_count > 0:
# 同步更新權限(新增缺少的、更新已有的)
existing_codes = {p.code for p in db_session.query(Permission).all()}
added = 0
updated = 0
for perm_data in PERMISSIONS:
if perm_data['code'] in existing_codes:
# 更新已有權限
perm = db_session.query(Permission).filter_by(code=perm_data['code']).first()
perm.name = perm_data['name']
perm.category = perm_data['category']
perm.description = perm_data.get('description')
perm.sort_order = perm_data.get('sort_order', 0)
updated += 1
else:
# 新增權限
perm = Permission(
code=perm_data['code'],
name=perm_data['name'],
category=perm_data['category'],
description=perm_data.get('description'),
sort_order=perm_data.get('sort_order', 0)
)
db_session.add(perm)
added += 1
db_session.commit()
return True, f"權限表已同步:新增 {added} 項,更新 {updated}"
# 首次初始化,批量新增
for perm_data in PERMISSIONS:
perm = Permission(
code=perm_data['code'],
name=perm_data['name'],
category=perm_data['category'],
description=perm_data.get('description'),
sort_order=perm_data.get('sort_order', 0)
)
db_session.add(perm)
db_session.commit()
return True, f"權限表初始化完成,共 {len(PERMISSIONS)} 項權限"
except Exception as e:
db_session.rollback()
return False, f"權限表初始化失敗: {str(e)}"

417
database/trend_models.py Normal file
View File

@@ -0,0 +1,417 @@
"""
趨勢資料庫模型
包含:
- TrendRecord: 趨勢記錄表
- TrendKeyword: 趨勢關鍵字表
- TrendAnalysis: AI 趨勢分析報告表
- WebSearchCache: Web Search 結果快取表
- TelegramUser: Telegram 用戶綁定表
"""
from sqlalchemy import (
Column, Integer, String, Text, Float, Boolean, DateTime, Date,
ForeignKey, Index, UniqueConstraint, BigInteger
)
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime, date, timedelta
import hashlib
import json
# 使用與其他模型相同的 Base
from database.models import Base
class TrendRecord(Base):
"""趨勢資料記錄 - 儲存爬取的原始內容"""
__tablename__ = 'trend_records'
id = Column(Integer, primary_key=True)
# 來源識別
source = Column(String(50), nullable=False, index=True)
# 可選值: 'google_news', 'ptt', 'dcard', 'youtube', 'weather', 'ollama_web_search'
source_board = Column(String(100))
# PTT/Dcard 看板名稱,如 'Gossiping', '網路購物'
source_url = Column(String(500))
# 原始連結
source_id = Column(String(100))
# 來源平台的唯一識別碼 (用於去重)
# 內容
title = Column(String(500), nullable=False)
content = Column(Text)
# 全文內容或摘要
author = Column(String(100))
# 作者/媒體名稱
# 互動指標
popularity_score = Column(Integer, default=0)
# 熱門度分數 (推數、讚數、觀看數等)
comment_count = Column(Integer, default=0)
# 留言數
# 分類標籤
category = Column(String(100), index=True)
# 商品分類對應: '美妝', '3C', '家電', '服飾' 等
tags = Column(Text)
# JSON 格式的標籤列表
# 時間資訊
published_at = Column(DateTime)
# 原始發布時間
trend_date = Column(Date, nullable=False, index=True)
# 趨勢所屬日期 (用於聚合查詢)
created_at = Column(DateTime, default=datetime.now)
# 爬取時間
# AI 分析結果
sentiment = Column(String(20))
# 情緒分析: 'positive', 'negative', 'neutral'
ai_summary = Column(Text)
# Ollama 生成的摘要
relevance_score = Column(Float, default=0.0)
# 與商品銷售的相關性分數 (0-1)
# 索引優化
__table_args__ = (
Index('idx_trend_source_date', 'source', 'trend_date'),
Index('idx_trend_category_date', 'category', 'trend_date'),
Index('idx_trend_popularity', 'popularity_score', 'trend_date'),
UniqueConstraint('source', 'source_id', name='uq_source_record'),
)
def to_dict(self):
"""轉換為字典"""
return {
'id': self.id,
'source': self.source,
'source_board': self.source_board,
'source_url': self.source_url,
'title': self.title,
'content': self.content[:200] if self.content else None,
'author': self.author,
'popularity_score': self.popularity_score,
'comment_count': self.comment_count,
'category': self.category,
'tags': json.loads(self.tags) if self.tags else [],
'published_at': self.published_at.isoformat() if self.published_at else None,
'trend_date': self.trend_date.isoformat() if self.trend_date else None,
'sentiment': self.sentiment,
'ai_summary': self.ai_summary,
'relevance_score': self.relevance_score,
}
class TrendKeyword(Base):
"""趨勢關鍵字 - 從文章中萃取的熱門詞彙"""
__tablename__ = 'trend_keywords'
id = Column(Integer, primary_key=True)
keyword = Column(String(100), nullable=False, index=True)
# 關鍵字
keyword_type = Column(String(50), default='general')
# 類型: 'product' (商品), 'brand' (品牌), 'event' (事件), 'general'
source = Column(String(50), nullable=False)
# 來源平台
category = Column(String(100), index=True)
# 商品分類
mention_count = Column(Integer, default=1)
# 提及次數
trend_date = Column(Date, nullable=False, index=True)
# 趨勢日期
sentiment_avg = Column(Float, default=0.0)
# 平均情緒分數 (-1 到 1)
related_keywords = Column(Text)
# JSON 格式的相關關鍵字
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
__table_args__ = (
Index('idx_keyword_date_count', 'trend_date', 'mention_count'),
UniqueConstraint('keyword', 'source', 'trend_date', name='uq_keyword_source_date'),
)
def to_dict(self):
"""轉換為字典"""
return {
'id': self.id,
'keyword': self.keyword,
'keyword_type': self.keyword_type,
'source': self.source,
'category': self.category,
'mention_count': self.mention_count,
'trend_date': self.trend_date.isoformat() if self.trend_date else None,
'sentiment_avg': self.sentiment_avg,
'related_keywords': json.loads(self.related_keywords) if self.related_keywords else [],
}
class TrendAnalysis(Base):
"""趨勢分析報告 - Ollama AI 生成的分析結果"""
__tablename__ = 'trend_analysis'
id = Column(Integer, primary_key=True)
analysis_date = Column(Date, nullable=False, index=True)
# 分析日期
category = Column(String(100), index=True)
# 分析的商品分類 (null 表示全品類)
analysis_type = Column(String(50), nullable=False)
# 分析類型: 'daily_summary', 'weekly_trend', 'hot_topic', 'marketing_insight'
# AI 分析內容
summary = Column(Text, nullable=False)
# 摘要說明
hot_keywords = Column(Text)
# JSON: 熱門關鍵字列表
hot_topics = Column(Text)
# JSON: 熱門話題列表
consumer_insights = Column(Text)
# JSON: 消費者洞察
marketing_suggestions = Column(Text)
# JSON: 行銷建議
copywriting_hints = Column(Text)
# JSON: 文案撰寫提示
# 來源統計
source_stats = Column(Text)
# JSON: 各來源資料統計
record_count = Column(Integer, default=0)
# 分析涵蓋的記錄數
# Ollama 資訊
model_used = Column(String(50))
# 使用的模型
generation_time = Column(Float)
# 生成耗時 (秒)
created_at = Column(DateTime, default=datetime.now)
__table_args__ = (
UniqueConstraint('analysis_date', 'category', 'analysis_type', name='uq_analysis'),
)
def to_dict(self):
"""轉換為字典"""
return {
'id': self.id,
'analysis_date': self.analysis_date.isoformat() if self.analysis_date else None,
'category': self.category,
'analysis_type': self.analysis_type,
'summary': self.summary,
'hot_keywords': json.loads(self.hot_keywords) if self.hot_keywords else [],
'hot_topics': json.loads(self.hot_topics) if self.hot_topics else [],
'consumer_insights': json.loads(self.consumer_insights) if self.consumer_insights else [],
'marketing_suggestions': json.loads(self.marketing_suggestions) if self.marketing_suggestions else [],
'copywriting_hints': json.loads(self.copywriting_hints) if self.copywriting_hints else [],
'source_stats': json.loads(self.source_stats) if self.source_stats else {},
'record_count': self.record_count,
'model_used': self.model_used,
'generation_time': self.generation_time,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
class WebSearchCache(Base):
"""Web Search 結果快取 - 避免重複查詢"""
__tablename__ = 'web_search_cache'
id = Column(Integer, primary_key=True)
# 查詢識別
query_hash = Column(String(64), nullable=False, unique=True, index=True)
# MD5(query + search_type)
query = Column(String(500), nullable=False)
# 原始查詢字串
search_type = Column(String(50), default='general')
# 搜尋類型: general, news, shopping, trends
# 結果
result_json = Column(Text, nullable=False)
# JSON 格式的完整結果
summary = Column(Text)
# AI 生成的摘要
result_count = Column(Integer, default=0)
# 結果數量
# 元資料
model_used = Column(String(50))
generation_time = Column(Float)
# 時間
created_at = Column(DateTime, default=datetime.now, index=True)
expires_at = Column(DateTime)
# 快取過期時間 (預設 24 小時)
__table_args__ = (
Index('idx_cache_query_type', 'query', 'search_type'),
Index('idx_cache_expires', 'expires_at'),
)
@staticmethod
def generate_hash(query: str, search_type: str) -> str:
"""產生查詢雜湊"""
return hashlib.md5(f"{query}:{search_type}".encode(), usedforsecurity=False).hexdigest()
def is_expired(self) -> bool:
"""檢查是否已過期"""
if not self.expires_at:
return True
return datetime.now() > self.expires_at
def to_dict(self):
"""轉換為字典"""
return {
'id': self.id,
'query': self.query,
'search_type': self.search_type,
'result': json.loads(self.result_json) if self.result_json else None,
'summary': self.summary,
'result_count': self.result_count,
'model_used': self.model_used,
'generation_time': self.generation_time,
'created_at': self.created_at.isoformat() if self.created_at else None,
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
'is_expired': self.is_expired(),
}
class TelegramUser(Base):
"""Telegram 用戶綁定表"""
__tablename__ = 'telegram_users'
id = Column(Integer, primary_key=True)
telegram_id = Column(BigInteger, unique=True, nullable=False, index=True)
# Telegram 用戶 ID
telegram_username = Column(String(100))
# Telegram 用戶名稱
user_id = Column(Integer, ForeignKey('users.id'))
# 綁定的系統用戶 ID (可選)
display_name = Column(String(100))
# 顯示名稱
is_active = Column(Boolean, default=True)
# 是否啟用
is_admin = Column(Boolean, default=False)
# 是否為管理員
# 偏好設定
notify_trends = Column(Boolean, default=True)
# 是否接收趨勢通知
notify_daily_summary = Column(Boolean, default=True)
# 是否接收每日摘要
preferred_categories = Column(Text)
# JSON: 偏好的分類列表
created_at = Column(DateTime, default=datetime.now)
last_active_at = Column(DateTime, default=datetime.now)
def to_dict(self):
"""轉換為字典"""
return {
'id': self.id,
'telegram_id': self.telegram_id,
'telegram_username': self.telegram_username,
'user_id': self.user_id,
'display_name': self.display_name,
'is_active': self.is_active,
'is_admin': self.is_admin,
'notify_trends': self.notify_trends,
'notify_daily_summary': self.notify_daily_summary,
'preferred_categories': json.loads(self.preferred_categories) if self.preferred_categories else [],
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_active_at': self.last_active_at.isoformat() if self.last_active_at else None,
}
# PTT 目標看板
PTT_BOARDS = [
'Gossiping', # 八卦板 - 熱門話題
'Lifeismoney', # 省錢板 - 優惠情報
'e-shopping', # 網購板 - 電商趨勢
'Beauty', # 美妝板 - 美妝趨勢
'MakeUp', # 化妝板 - 彩妝趨勢
'WomenTalk', # 女板 - 女性消費趨勢
'home-sale', # 房屋板 - 居家用品參考
'BabyMother', # 媽寶板 - 母嬰市場
'Tech_Job', # 科技業 - 3C 消費力
]
# Dcard 目標看板
DCARD_BOARDS = [
'網路購物', # 電商討論
'美妝', # 美妝趨勢
'穿搭', # 服飾趨勢
'3C', # 科技產品
'省錢', # 優惠情報
'生活', # 生活趨勢
'美食', # 餐飲趨勢
]
# 看板對應分類
BOARD_CATEGORY_MAPPING = {
# PTT
'Beauty': '美妝',
'MakeUp': '美妝',
'e-shopping': '電商',
'Lifeismoney': '優惠',
'home-sale': '居家',
'BabyMother': '母嬰',
'Gossiping': '熱門',
'WomenTalk': '生活',
'Tech_Job': '3C',
# Dcard
'美妝': '美妝',
'穿搭': '服飾',
'3C': '3C',
'網路購物': '電商',
'省錢': '優惠',
'生活': '生活',
'美食': '美食',
}
def get_category_for_board(board: str) -> str:
"""根據看板名稱取得商品分類"""
return BOARD_CATEGORY_MAPPING.get(board, '其他')

110
database/user_models.py Normal file
View File

@@ -0,0 +1,110 @@
"""
用戶與登入歷史資料模型
提供:
- User: 用戶帳號表
- LoginHistory: 登入歷史記錄表
"""
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime
from database.models import Base
class User(Base):
"""用戶帳號表"""
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
username = Column(String(50), unique=True, nullable=False, index=True)
email = Column(String(120), unique=True, nullable=True)
password_hash = Column(String(256), nullable=False)
role = Column(String(20), default='user', index=True) # admin, manager, user
display_name = Column(String(100))
is_active = Column(Boolean, default=True)
password_changed_at = Column(DateTime) # 密碼變更時間
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, onupdate=datetime.now)
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
# 關聯
login_history = relationship("LoginHistory", back_populates="user", cascade="all, delete-orphan")
# 角色常數
ROLE_ADMIN = 'admin'
ROLE_MANAGER = 'manager'
ROLE_USER = 'user'
ROLES = [ROLE_ADMIN, ROLE_MANAGER, ROLE_USER]
ROLE_LABELS = {
'admin': '系統管理員',
'manager': '管理者',
'user': '一般用戶'
}
def get_role_label(self):
"""取得角色顯示名稱"""
return self.ROLE_LABELS.get(self.role, self.role)
def is_admin(self):
"""是否為管理員"""
return self.role == self.ROLE_ADMIN
def is_manager_or_above(self):
"""是否為管理者或以上"""
return self.role in [self.ROLE_ADMIN, self.ROLE_MANAGER]
def to_dict(self):
"""轉換為字典"""
return {
'id': self.id,
'username': self.username,
'email': self.email,
'role': self.role,
'role_label': self.get_role_label(),
'display_name': self.display_name,
'is_active': self.is_active,
'password_changed_at': self.password_changed_at.isoformat() if self.password_changed_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
class LoginHistory(Base):
"""登入歷史記錄表"""
__tablename__ = 'login_history'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=True) # 允許 NULL記錄失敗的登入嘗試
username_attempted = Column(String(50)) # 嘗試登入的帳號名稱
login_time = Column(DateTime, default=datetime.now, index=True)
ip_address = Column(String(45)) # 支援 IPv6
user_agent = Column(String(256))
status = Column(String(20), index=True) # success, failed, locked
failure_reason = Column(String(100))
# 關聯
user = relationship("User", back_populates="login_history")
# 狀態常數
STATUS_SUCCESS = 'success'
STATUS_FAILED = 'failed'
STATUS_LOCKED = 'locked'
def to_dict(self):
"""轉換為字典"""
return {
'id': self.id,
'user_id': self.user_id,
'username_attempted': self.username_attempted,
'login_time': self.login_time.isoformat() if self.login_time else None,
'ip_address': self.ip_address,
'user_agent': self.user_agent,
'status': self.status,
'failure_reason': self.failure_reason,
}
print("✅ User models 已載入")

665
database/vendor_manager.py Normal file
View File

@@ -0,0 +1,665 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
廠商缺貨通知系統 - 資料庫管理器
提供廠商缺貨資料的 CRUD 操作
"""
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from datetime import datetime
from .vendor_models import Base, VendorStockout, VendorList, VendorEmail, EmailSendLog
# 導入日誌管理模組
from services.logger_manager import SystemLogger
# 初始化日誌
sys_log = SystemLogger("VendorDatabase").get_logger()
class VendorDatabaseManager:
"""廠商缺貨系統資料庫管理器"""
def __init__(self, db_path=None):
"""
初始化資料庫連線。
優先使用 PostgreSQL (透過 config.py 設定),否則回退到 SQLite。
Args:
db_path: 資料庫檔案路徑 (僅 SQLite 模式使用),若為 None 則使用預設路徑
"""
# V-Fix (2026-01-23): 優先使用 config.py 的資料庫設定,與主 DatabaseManager 保持一致
from config import DATABASE_PATH, DATABASE_TYPE
if DATABASE_TYPE == 'postgresql':
# PostgreSQL 模式 - 使用 config.py 的連線字串
self.engine = create_engine(DATABASE_PATH, echo=False, pool_pre_ping=True)
self.Session = sessionmaker(bind=self.engine)
Base.metadata.create_all(self.engine)
sys_log.info(f"[VendorDatabase] ✅ 使用 PostgreSQL 資料庫")
else:
# SQLite 模式 - 向後相容
if db_path is None:
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
db_path = os.path.join(base_dir, 'data', 'momo_database.db')
sys_log.info(f"廠商缺貨系統資料庫連線初始化 | Path: {db_path}")
# 確保資料夾存在
os.makedirs(os.path.dirname(db_path), exist_ok=True)
# 建立引擎與 Session
self.engine = create_engine(f'sqlite:///{db_path}', echo=False)
Base.metadata.create_all(self.engine)
self.Session = sessionmaker(bind=self.engine)
sys_log.info("[VendorDatabase] 使用 SQLite 資料庫")
sys_log.info("✅ 廠商缺貨系統資料表已建立/更新")
def get_session(self):
"""
提供外部調用的 Session 實例。
Returns:
sqlalchemy.orm.Session: 資料庫 Session
"""
return self.Session()
# ==========================================
# 廠商清單管理
# ==========================================
def add_vendor(self, vendor_code, vendor_name, is_active=True):
"""
新增廠商
Args:
vendor_code: 廠商代碼
vendor_name: 廠商名稱
is_active: 是否啟用
Returns:
VendorList: 新增的廠商物件,若已存在則回傳 None
"""
session = self.get_session()
try:
# 檢查是否已存在
existing = session.query(VendorList).filter_by(vendor_code=vendor_code).first()
if existing:
sys_log.warning(f"廠商已存在 | 代碼: {vendor_code}")
return None
# 建立新廠商
vendor = VendorList(
vendor_code=vendor_code,
vendor_name=vendor_name,
is_active=is_active
)
session.add(vendor)
session.commit()
sys_log.info(f"✅ 新增廠商成功 | 代碼: {vendor_code} | 名稱: {vendor_name}")
return vendor
except Exception as e:
session.rollback()
sys_log.error(f"❌ 新增廠商失敗 | 代碼: {vendor_code} | 錯誤: {e}")
return None
finally:
session.close()
def get_vendor_by_code(self, vendor_code):
"""
根據廠商代碼查詢廠商
Args:
vendor_code: 廠商代碼
Returns:
VendorList: 廠商物件,若不存在則回傳 None
"""
session = self.get_session()
try:
vendor = session.query(VendorList).filter_by(vendor_code=vendor_code).first()
return vendor
finally:
session.close()
def get_all_vendors(self, active_only=True):
"""
取得所有廠商清單
Args:
active_only: 是否只取得啟用中的廠商
Returns:
list: 廠商物件清單
"""
session = self.get_session()
try:
query = session.query(VendorList)
if active_only:
query = query.filter_by(is_active=True)
vendors = query.order_by(VendorList.vendor_code).all()
return vendors
finally:
session.close()
def update_vendor(self, vendor_code, vendor_name=None, is_active=None):
"""
更新廠商資訊
Args:
vendor_code: 廠商代碼
vendor_name: 廠商名稱 (可選)
is_active: 是否啟用 (可選)
Returns:
bool: 是否更新成功
"""
session = self.get_session()
try:
vendor = session.query(VendorList).filter_by(vendor_code=vendor_code).first()
if not vendor:
sys_log.warning(f"廠商不存在 | 代碼: {vendor_code}")
return False
# 更新欄位
if vendor_name is not None:
vendor.vendor_name = vendor_name
if is_active is not None:
vendor.is_active = is_active
vendor.updated_at = datetime.now()
session.commit()
sys_log.info(f"✅ 更新廠商成功 | 代碼: {vendor_code}")
return True
except Exception as e:
session.rollback()
sys_log.error(f"❌ 更新廠商失敗 | 代碼: {vendor_code} | 錯誤: {e}")
return False
finally:
session.close()
def delete_vendor(self, vendor_code):
"""
刪除廠商 (會連帶刪除相關的郵件與發送記錄)
Args:
vendor_code: 廠商代碼
Returns:
bool: 是否刪除成功
"""
session = self.get_session()
try:
vendor = session.query(VendorList).filter_by(vendor_code=vendor_code).first()
if not vendor:
sys_log.warning(f"廠商不存在 | 代碼: {vendor_code}")
return False
session.delete(vendor)
session.commit()
sys_log.info(f"✅ 刪除廠商成功 | 代碼: {vendor_code}")
return True
except Exception as e:
session.rollback()
sys_log.error(f"❌ 刪除廠商失敗 | 代碼: {vendor_code} | 錯誤: {e}")
return False
finally:
session.close()
# ==========================================
# 廠商郵件管理
# ==========================================
def add_vendor_email(self, vendor_code, email, contact_name=None,
email_type='primary', is_active=True, notes=None):
"""
新增廠商郵件(自動去重)
Args:
vendor_code: 廠商代碼
email: 郵件地址
contact_name: 聯絡人姓名
email_type: 郵件類型 (primary/cc/bcc)
is_active: 是否啟用
notes: 備註
Returns:
VendorEmail: 新增的郵件物件,若失敗或已存在則回傳 None
"""
session = self.get_session()
try:
# 查詢廠商
vendor = session.query(VendorList).filter_by(vendor_code=vendor_code).first()
if not vendor:
sys_log.warning(f"廠商不存在 | 代碼: {vendor_code}")
return None
# 檢查郵件是否已存在(去重)
existing_email = session.query(VendorEmail).filter_by(
vendor_id=vendor.id,
email=email
).first()
if existing_email:
sys_log.debug(f"郵件已存在,跳過 | 廠商: {vendor_code} | 郵件: {email}")
return None
# 建立新郵件
vendor_email = VendorEmail(
vendor_id=vendor.id,
email=email,
contact_name=contact_name,
email_type=email_type,
is_active=is_active,
notes=notes
)
session.add(vendor_email)
session.commit()
sys_log.info(f"✅ 新增廠商郵件成功 | 廠商: {vendor_code} | 郵件: {email}")
return vendor_email
except Exception as e:
session.rollback()
sys_log.error(f"❌ 新增廠商郵件失敗 | 廠商: {vendor_code} | 錯誤: {e}")
return None
finally:
session.close()
def get_vendor_emails(self, vendor_code, active_only=True):
"""
取得廠商的所有郵件
Args:
vendor_code: 廠商代碼
active_only: 是否只取得啟用中的郵件
Returns:
dict: 郵件清單,依類型分類 {'primary': [...], 'cc': [...], 'bcc': [...]}
"""
session = self.get_session()
try:
vendor = session.query(VendorList).filter_by(vendor_code=vendor_code).first()
if not vendor:
return {'primary': [], 'cc': [], 'bcc': []}
query = session.query(VendorEmail).filter_by(vendor_id=vendor.id)
if active_only:
query = query.filter_by(is_active=True)
emails = query.all()
# 依類型分類
result = {'primary': [], 'cc': [], 'bcc': []}
for email in emails:
result[email.email_type].append(email)
return result
finally:
session.close()
def update_vendor_email(self, email_id, email=None, contact_name=None,
email_type=None, is_active=None, notes=None):
"""
更新廠商郵件
Args:
email_id: 郵件 ID
email: 郵件地址 (可選)
contact_name: 聯絡人姓名 (可選)
email_type: 郵件類型 (可選)
is_active: 是否啟用 (可選)
notes: 備註 (可選)
Returns:
bool: 是否更新成功
"""
session = self.get_session()
try:
vendor_email = session.query(VendorEmail).filter_by(id=email_id).first()
if not vendor_email:
sys_log.warning(f"郵件不存在 | ID: {email_id}")
return False
# 更新欄位
if email is not None:
vendor_email.email = email
if contact_name is not None:
vendor_email.contact_name = contact_name
if email_type is not None:
vendor_email.email_type = email_type
if is_active is not None:
vendor_email.is_active = is_active
if notes is not None:
vendor_email.notes = notes
vendor_email.updated_at = datetime.now()
session.commit()
sys_log.info(f"✅ 更新廠商郵件成功 | ID: {email_id}")
return True
except Exception as e:
session.rollback()
sys_log.error(f"❌ 更新廠商郵件失敗 | ID: {email_id} | 錯誤: {e}")
return False
finally:
session.close()
def delete_vendor_email(self, email_id):
"""
刪除廠商郵件
Args:
email_id: 郵件 ID
Returns:
bool: 是否刪除成功
"""
session = self.get_session()
try:
vendor_email = session.query(VendorEmail).filter_by(id=email_id).first()
if not vendor_email:
sys_log.warning(f"郵件不存在 | ID: {email_id}")
return False
session.delete(vendor_email)
session.commit()
sys_log.info(f"✅ 刪除廠商郵件成功 | ID: {email_id}")
return True
except Exception as e:
session.rollback()
sys_log.error(f"❌ 刪除廠商郵件失敗 | ID: {email_id} | 錯誤: {e}")
return False
finally:
session.close()
# ==========================================
# 缺貨記錄管理
# ==========================================
def add_stockout_records(self, records_list, batch_id):
"""
批次新增缺貨記錄 (用於 Excel 匯入)
Args:
records_list: 缺貨記錄清單 (dict list)
batch_id: 批次編號
Returns:
tuple: (成功筆數, 失敗筆數, 重複筆數)
"""
session = self.get_session()
success_count = 0
failed_count = 0
duplicate_count = 0
try:
for record in records_list:
try:
# 檢查是否為重複資料 (同一天 + 同廠商 + 同商品)
import_date = record.get('import_date', datetime.now().date())
vendor_code = record.get('vendor_code')
product_code = record.get('product_code')
existing = session.query(VendorStockout).filter_by(
import_date=import_date,
vendor_code=vendor_code,
product_code=product_code
).first()
if existing:
# 標記為重複並更新計數
existing.duplicate_count += 1
existing.status = 'duplicate'
duplicate_count += 1
sys_log.debug(f"偵測到重複資料 | 廠商: {vendor_code} | 商品: {product_code}")
continue
# 建立新記錄
stockout = VendorStockout(
batch_id=batch_id,
import_date=import_date,
department=record.get('department'),
section=record.get('section'),
pm_name=record.get('pm_name'),
zone_id=record.get('zone_id'),
zone_name=record.get('zone_name'),
product_code=product_code,
product_name=record.get('product_name'),
product_spec=record.get('product_spec'),
borrow_transfer=record.get('borrow_transfer'),
sale_price=record.get('sale_price'),
cost_price=record.get('cost_price'),
vendor_code=vendor_code,
vendor_name=record.get('vendor_name'),
monthly_sales_qty=record.get('monthly_sales_qty'),
monthly_sales_amount=record.get('monthly_sales_amount'),
daily_avg_sales=record.get('daily_avg_sales'),
current_stock=record.get('current_stock'),
stockout_date=record.get('stockout_date'),
stockout_days=record.get('stockout_days'),
safe_stock_days=record.get('safe_stock_days'),
notes=record.get('notes')
)
session.add(stockout)
success_count += 1
# 每 100 筆提交一次
if success_count % 100 == 0:
session.commit()
except Exception as e:
sys_log.error(f"❌ 新增缺貨記錄失敗 | 錯誤: {e}")
failed_count += 1
continue
# 最後提交
session.commit()
sys_log.info(f"✅ 批次匯入完成 | 成功: {success_count} | 失敗: {failed_count} | 重複: {duplicate_count}")
return success_count, failed_count, duplicate_count
except Exception as e:
session.rollback()
sys_log.error(f"❌ 批次匯入失敗 | 錯誤: {e}")
return success_count, failed_count, duplicate_count
finally:
session.close()
def get_stockout_records(self, batch_id=None, vendor_code=None, status=None,
start_date=None, end_date=None, limit=None):
"""
查詢缺貨記錄
Args:
batch_id: 批次編號 (可選)
vendor_code: 廠商代碼 (可選)
status: 狀態 (可選)
start_date: 開始日期 (可選)
end_date: 結束日期 (可選)
limit: 限制筆數 (可選)
Returns:
list: 缺貨記錄清單
"""
session = self.get_session()
try:
query = session.query(VendorStockout)
# 篩選條件
if batch_id:
query = query.filter_by(batch_id=batch_id)
if vendor_code:
query = query.filter_by(vendor_code=vendor_code)
if status:
query = query.filter_by(status=status)
if start_date:
query = query.filter(VendorStockout.import_date >= start_date)
if end_date:
query = query.filter(VendorStockout.import_date <= end_date)
# 排序與限制
query = query.order_by(VendorStockout.import_date.desc())
if limit:
query = query.limit(limit)
records = query.all()
return records
finally:
session.close()
def update_stockout_status(self, stockout_id, status, error_message=None,
sent_date=None, sent_by=None):
"""
更新缺貨記錄狀態
Args:
stockout_id: 缺貨記錄 ID
status: 狀態
error_message: 錯誤訊息 (可選)
sent_date: 發送時間 (可選)
sent_by: 發送人員 (可選)
Returns:
bool: 是否更新成功
"""
session = self.get_session()
try:
stockout = session.query(VendorStockout).filter_by(id=stockout_id).first()
if not stockout:
sys_log.warning(f"缺貨記錄不存在 | ID: {stockout_id}")
return False
stockout.status = status
if error_message is not None:
stockout.error_message = error_message
if sent_date is not None:
stockout.sent_date = sent_date
if sent_by is not None:
stockout.sent_by = sent_by
stockout.updated_at = datetime.now()
session.commit()
sys_log.debug(f"更新缺貨記錄狀態 | ID: {stockout_id} | 狀態: {status}")
return True
except Exception as e:
session.rollback()
sys_log.error(f"❌ 更新缺貨記錄狀態失敗 | ID: {stockout_id} | 錯誤: {e}")
return False
finally:
session.close()
# ==========================================
# 郵件發送記錄管理
# ==========================================
def add_email_log(self, vendor_code, batch_id, sender_email, recipient_email,
subject, product_count, cc_emails=None, bcc_emails=None,
attachment_filename=None, attachment_size=None, stockout_id=None):
"""
新增郵件發送記錄
Args:
vendor_code: 廠商代碼
batch_id: 發送批次編號
sender_email: 寄件者郵件
recipient_email: 收件者郵件
subject: 郵件主旨
product_count: 商品數量
cc_emails: CC 郵件清單 (JSON 字串)
bcc_emails: BCC 郵件清單 (JSON 字串)
attachment_filename: 附件檔名
attachment_size: 附件大小
stockout_id: 缺貨記錄 ID
Returns:
EmailSendLog: 新增的記錄物件,若失敗則回傳 None
"""
session = self.get_session()
try:
# 查詢廠商
vendor = session.query(VendorList).filter_by(vendor_code=vendor_code).first()
if not vendor:
sys_log.warning(f"廠商不存在 | 代碼: {vendor_code}")
return None
# 建立記錄
log = EmailSendLog(
vendor_id=vendor.id,
stockout_id=stockout_id,
batch_id=batch_id,
sender_email=sender_email,
recipient_email=recipient_email,
cc_emails=cc_emails,
bcc_emails=bcc_emails,
subject=subject,
product_count=product_count,
attachment_filename=attachment_filename,
attachment_size=attachment_size,
status='pending'
)
session.add(log)
session.commit()
sys_log.debug(f"新增郵件發送記錄 | 廠商: {vendor_code} | 收件者: {recipient_email}")
return log
except Exception as e:
session.rollback()
sys_log.error(f"❌ 新增郵件發送記錄失敗 | 廠商: {vendor_code} | 錯誤: {e}")
return None
finally:
session.close()
def update_email_log_status(self, log_id, status, error_message=None, sent_at=None):
"""
更新郵件發送記錄狀態
Args:
log_id: 記錄 ID
status: 狀態
error_message: 錯誤訊息 (可選)
sent_at: 發送時間 (可選)
Returns:
bool: 是否更新成功
"""
session = self.get_session()
try:
log = session.query(EmailSendLog).filter_by(id=log_id).first()
if not log:
sys_log.warning(f"郵件發送記錄不存在 | ID: {log_id}")
return False
log.status = status
if error_message is not None:
log.error_message = error_message
if sent_at is not None:
log.sent_at = sent_at
session.commit()
sys_log.debug(f"更新郵件發送記錄狀態 | ID: {log_id} | 狀態: {status}")
return True
except Exception as e:
session.rollback()
sys_log.error(f"❌ 更新郵件發送記錄狀態失敗 | ID: {log_id} | 錯誤: {e}")
return False
finally:
session.close()

165
database/vendor_models.py Normal file
View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
廠商缺貨通知系統 - 資料庫模型
包含廠商缺貨表、廠商清單、廠商郵件、郵件發送記錄
"""
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Numeric, Date, Text
from sqlalchemy.orm import relationship, declarative_base
from datetime import datetime
Base = declarative_base()
class VendorStockout(Base):
"""廠商缺貨表 - 儲存匯入的缺貨資料"""
__tablename__ = 'vendor_stockout'
# 主鍵
id = Column(Integer, primary_key=True, autoincrement=True)
# 匯入批次資訊
batch_id = Column(String(50), nullable=False, index=True, comment='批次編號 (格式: YYYYMMDD_HHMMSS)')
import_date = Column(Date, nullable=False, index=True, comment='匯入日期')
import_time = Column(DateTime, nullable=False, default=datetime.now, comment='匯入時間')
# 組織資訊
department = Column(String(100), comment='部別')
section = Column(String(100), comment='課別')
pm_name = Column(String(100), index=True, comment='PM 姓名')
zone_id = Column(String(100), comment='區ID')
zone_name = Column(String(200), comment='區名稱')
# 商品資訊
product_code = Column(String(100), nullable=False, index=True, comment='商品料號')
product_name = Column(String(500), nullable=False, comment='商品名稱')
product_spec = Column(Text, comment='商品規格')
borrow_transfer = Column(String(100), comment='借採轉')
sale_price = Column(Numeric(10, 2), comment='售價')
cost_price = Column(Numeric(10, 2), comment='成本')
# 廠商資訊
vendor_code = Column(String(100), nullable=False, index=True, comment='廠商代碼')
vendor_name = Column(String(200), nullable=False, index=True, comment='廠商名稱')
# 業績資訊
monthly_sales_qty = Column(Integer, comment='全月銷量')
monthly_sales_amount = Column(Numeric(12, 2), comment='全月業績')
daily_avg_sales = Column(Numeric(10, 2), comment='日均銷量')
# 庫存資訊
current_stock = Column(Integer, comment='現有庫存')
stockout_date = Column(Date, comment='缺貨日期')
stockout_days = Column(Integer, comment='缺貨天數')
safe_stock_days = Column(Integer, comment='安全庫存天數')
# 狀態追蹤
status = Column(String(20), nullable=False, default='pending', index=True,
comment='狀態: pending(待發送), sent(已發送), failed(失敗), duplicate(重複)')
is_duplicate = Column(Boolean, default=False, index=True, comment='是否為重複資料')
duplicate_count = Column(Integer, default=0, comment='重複次數')
# 發送記錄
sent_date = Column(DateTime, comment='發送時間')
sent_by = Column(String(100), comment='發送人員')
error_message = Column(Text, comment='錯誤訊息')
# 備註與時間戳記
notes = Column(Text, comment='備註')
created_at = Column(DateTime, default=datetime.now, nullable=False, comment='建立時間')
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False, comment='更新時間')
# 關聯設定
email_logs = relationship("EmailSendLog", back_populates="stockout_item", cascade="all, delete-orphan")
class VendorList(Base):
"""廠商清單表 - 管理廠商基本資料"""
__tablename__ = 'vendor_list'
# 主鍵
id = Column(Integer, primary_key=True, autoincrement=True)
# 廠商基本資訊
vendor_code = Column(String(100), unique=True, nullable=False, index=True, comment='廠商代碼')
vendor_name = Column(String(200), nullable=False, comment='廠商名稱')
# 狀態
is_active = Column(Boolean, default=True, nullable=False, index=True, comment='是否啟用')
# 時間戳記
created_at = Column(DateTime, default=datetime.now, nullable=False, comment='建立時間')
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False, comment='更新時間')
# 關聯設定
emails = relationship("VendorEmail", back_populates="vendor", cascade="all, delete-orphan")
email_logs = relationship("EmailSendLog", back_populates="vendor", cascade="all, delete-orphan")
class VendorEmail(Base):
"""廠商郵件表 - 管理廠商的多個聯絡郵件"""
__tablename__ = 'vendor_emails'
# 主鍵
id = Column(Integer, primary_key=True, autoincrement=True)
# 外鍵
vendor_id = Column(Integer, ForeignKey('vendor_list.id'), nullable=False, index=True, comment='廠商ID')
# 郵件資訊
email = Column(String(255), nullable=False, comment='電子郵件地址')
contact_name = Column(String(100), comment='聯絡人姓名')
email_type = Column(String(20), nullable=False, default='primary',
comment='郵件類型: primary(主要), cc(副本), bcc(密件副本)')
# 狀態
is_active = Column(Boolean, default=True, nullable=False, index=True, comment='是否啟用')
# 備註與時間戳記
notes = Column(Text, comment='備註')
created_at = Column(DateTime, default=datetime.now, nullable=False, comment='建立時間')
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False, comment='更新時間')
# 關聯設定
vendor = relationship("VendorList", back_populates="emails")
class EmailSendLog(Base):
"""郵件發送記錄表 - 完整稽核追蹤"""
__tablename__ = 'email_send_log'
# 主鍵
id = Column(Integer, primary_key=True, autoincrement=True)
# 外鍵
vendor_id = Column(Integer, ForeignKey('vendor_list.id'), nullable=False, index=True, comment='廠商ID')
stockout_id = Column(Integer, ForeignKey('vendor_stockout.id'), index=True, comment='缺貨記錄ID')
# 批次資訊
batch_id = Column(String(50), nullable=False, index=True, comment='發送批次編號')
# 郵件資訊
sender_email = Column(String(255), nullable=False, comment='寄件者郵件')
recipient_email = Column(String(255), nullable=False, comment='收件者郵件')
cc_emails = Column(Text, comment='CC 郵件清單 (JSON 格式)')
bcc_emails = Column(Text, comment='BCC 郵件清單 (JSON 格式)')
subject = Column(String(500), nullable=False, comment='郵件主旨')
# 內容資訊
product_count = Column(Integer, nullable=False, comment='商品數量')
attachment_filename = Column(String(255), comment='附件檔名')
attachment_size = Column(Integer, comment='附件大小 (bytes)')
# 發送狀態
status = Column(String(20), nullable=False, default='pending', index=True,
comment='狀態: pending(待發送), sent(成功), failed(失敗)')
error_message = Column(Text, comment='錯誤訊息')
retry_count = Column(Integer, default=0, comment='重試次數')
# 時間戳記
sent_at = Column(DateTime, comment='發送時間')
created_at = Column(DateTime, default=datetime.now, nullable=False, comment='建立時間')
# 關聯設定
vendor = relationship("VendorList", back_populates="email_logs")
stockout_item = relationship("VendorStockout", back_populates="email_logs")