- 建立 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:
0
database/__init__.py
Normal file
0
database/__init__.py
Normal file
319
database/ai_models.py
Normal file
319
database/ai_models.py
Normal 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
145
database/edm_dashboard.html
Normal 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
30
database/edm_models.py
Normal 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
115
database/import_models.py
Normal 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
357
database/manager.py
Normal 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
108
database/models.py
Normal 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'),
|
||||
)
|
||||
236
database/notification_models.py
Normal file
236
database/notification_models.py
Normal 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'''
|
||||
}
|
||||
]
|
||||
233
database/permission_models.py
Normal file
233
database/permission_models.py
Normal 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
417
database/trend_models.py
Normal 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
110
database/user_models.py
Normal 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
665
database/vendor_manager.py
Normal 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
165
database/vendor_models.py
Normal 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")
|
||||
Reference in New Issue
Block a user