feat(frontend): 新增廠商缺貨 V2 入口

This commit is contained in:
OoO
2026-05-01 00:04:12 +08:00
parent 3398c15a75
commit 6c73c57a91
3 changed files with 567 additions and 2 deletions

4
app.py
View File

@@ -95,8 +95,8 @@ except Exception as e:
sys_log.error(f"無法檢測磁碟空間: {e}")
# 🚩 系統版本定義 (備份與顯示用)
# 🚩 2026-04-30 V10.36: EDM dashboard v2 feature flag
SYSTEM_VERSION = "V10.36"
# 🚩 2026-04-30 V10.37: Vendor stockout v2 feature flag
SYSTEM_VERSION = "V10.37"
# ==========================================
# 🔒 SQL Injection 防護函數

View File

@@ -35,10 +35,78 @@ vendor_db = VendorDatabaseManager()
# 主要頁面路由
# ==========================================
def _get_vendor_dashboard_stats():
"""彙整廠商缺貨首頁 V2 所需的真實資料庫統計。"""
session = vendor_db.get_session()
try:
total_stockouts = session.query(VendorStockout).count()
pending_stockouts = session.query(VendorStockout).filter(or_(
VendorStockout.status == 'pending',
VendorStockout.status == None
)).count()
sent_stockouts = session.query(VendorStockout).filter(VendorStockout.status == 'sent').count()
failed_stockouts = session.query(VendorStockout).filter(VendorStockout.status == 'failed').count()
duplicate_stockouts = session.query(VendorStockout).filter(VendorStockout.is_duplicate == True).count()
distinct_vendor_count = session.query(VendorStockout.vendor_code).distinct().count()
active_vendors = session.query(VendorList).filter(VendorList.is_active == True).count()
inactive_vendors = session.query(VendorList).filter(VendorList.is_active == False).count()
active_emails = session.query(VendorEmail).filter(VendorEmail.is_active == True).count()
email_total = session.query(EmailSendLog).count()
email_success = session.query(EmailSendLog).filter(EmailSendLog.status == 'sent').count()
email_failed = session.query(EmailSendLog).filter(EmailSendLog.status == 'failed').count()
email_pending = session.query(EmailSendLog).filter(EmailSendLog.status == 'pending').count()
email_success_rate = round(email_success / email_total * 100, 1) if email_total else 0
latest_import = session.query(VendorStockout).order_by(desc(VendorStockout.created_at)).first()
latest_email = session.query(EmailSendLog).order_by(desc(EmailSendLog.created_at)).first()
latest_batch = session.query(
VendorStockout.batch_id,
func.count(VendorStockout.id).label('record_count'),
func.max(VendorStockout.created_at).label('latest_date')
).group_by(VendorStockout.batch_id).order_by(desc('latest_date')).first()
return {
'total_stockouts': total_stockouts,
'pending_stockouts': pending_stockouts,
'sent_stockouts': sent_stockouts,
'failed_stockouts': failed_stockouts,
'duplicate_stockouts': duplicate_stockouts,
'distinct_vendor_count': distinct_vendor_count,
'active_vendors': active_vendors,
'inactive_vendors': inactive_vendors,
'active_emails': active_emails,
'email_total': email_total,
'email_success': email_success,
'email_failed': email_failed,
'email_pending': email_pending,
'email_success_rate': email_success_rate,
'latest_import_time': latest_import.created_at if latest_import else None,
'latest_import_vendor': latest_import.vendor_name if latest_import else None,
'latest_import_product': latest_import.product_name if latest_import else None,
'latest_email_time': latest_email.created_at if latest_email else None,
'latest_email_status': latest_email.status if latest_email else None,
'latest_email_vendor_id': latest_email.vendor_id if latest_email else None,
'latest_batch_id': latest_batch.batch_id if latest_batch else None,
'latest_batch_count': latest_batch.record_count if latest_batch else 0,
'latest_batch_time': latest_batch.latest_date if latest_batch else None,
}
finally:
session.close()
@vendor_bp.route('/')
def index():
"""廠商缺貨系統主頁"""
sys_log.info("[VendorStockout] 進入廠商缺貨系統主頁")
if request.args.get('ui') == 'v2':
return render_template(
'vendor_stockout_index_v2.html',
active_page='vendor_stockout',
stats=_get_vendor_dashboard_stats()
)
return render_template('vendor_stockout/index.html')

View File

@@ -0,0 +1,497 @@
{% extends 'ewoooc_base.html' %}
{% block title %}EwoooC 廠商缺貨{% endblock %}
{% block extra_css %}
<style>
.vendor-v2-stack {
display: grid;
gap: 24px;
}
.vendor-hero {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.8fr);
gap: 12px;
}
.vendor-hero-panel,
.vendor-card,
.vendor-table-card {
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border-light);
border-radius: 8px;
}
.vendor-hero-panel {
display: flex;
min-height: 228px;
flex-direction: column;
justify-content: space-between;
padding: 24px;
}
.vendor-eyebrow {
display: inline-flex;
width: fit-content;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 4px 10px;
color: var(--momo-accent-strong);
background: var(--momo-accent-soft);
border-radius: var(--momo-radius-pill);
font-size: 11px;
font-weight: 800;
}
.vendor-title {
margin: 0;
color: var(--momo-text-primary);
font-size: 34px;
font-weight: 800;
letter-spacing: 0;
line-height: 1.12;
}
.vendor-subtitle {
max-width: 680px;
margin: 10px 0 0;
color: var(--momo-text-secondary);
font-size: 14px;
line-height: 1.8;
}
.vendor-actions {
display: flex;
gap: 8px;
margin-top: 22px;
flex-wrap: wrap;
}
.vendor-action {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 8px 14px;
color: var(--momo-text-primary);
background: var(--momo-bg-paper);
border: 1px solid var(--momo-border-light);
border-radius: 4px;
font-size: 13px;
font-weight: 800;
text-decoration: none;
transition: var(--momo-transition-base);
}
.vendor-action:hover {
color: var(--momo-text-primary);
border-color: var(--momo-border-strong);
transform: translateY(-1px);
}
.vendor-action.is-primary {
color: var(--momo-text-inverse);
background: var(--momo-ink);
border-color: var(--momo-ink);
}
.vendor-pulse {
display: grid;
align-content: stretch;
padding: 18px;
background: var(--momo-ink);
border: 1px solid var(--momo-ink);
border-radius: 8px;
}
.vendor-pulse-label {
color: rgba(250, 247, 240, 0.62);
font-size: 11px;
font-weight: 800;
}
.vendor-pulse-value {
margin-top: 8px;
color: var(--momo-text-inverse);
font-size: 34px;
font-weight: 800;
line-height: 1;
}
.vendor-pulse-meta {
margin-top: 12px;
color: rgba(250, 247, 240, 0.7);
font-size: 12px;
line-height: 1.7;
}
.vendor-kpi-grid,
.vendor-flow-grid {
display: grid;
gap: 12px;
}
.vendor-kpi-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.vendor-flow-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.vendor-card {
padding: 18px;
}
.vendor-kpi-label,
.vendor-card-label {
color: var(--momo-text-secondary);
font-size: 11px;
font-weight: 800;
}
.vendor-kpi-value {
margin-top: 8px;
color: var(--momo-text-primary);
font-size: 30px;
font-weight: 800;
line-height: 1;
}
.vendor-kpi-sub {
margin-top: 8px;
color: var(--momo-text-tertiary);
font-size: 12px;
}
.vendor-kpi-value.is-danger {
color: var(--momo-danger);
}
.vendor-kpi-value.is-success {
color: var(--momo-success);
}
.vendor-section-label {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 12px;
}
.vendor-section-label .num {
color: var(--momo-text-tertiary);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
}
.vendor-section-label .title {
color: var(--momo-text-primary);
font-size: 13px;
font-weight: 800;
}
.vendor-flow-card {
display: flex;
min-height: 138px;
flex-direction: column;
justify-content: space-between;
padding: 16px;
color: var(--momo-text-primary);
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border-light);
border-radius: 8px;
text-decoration: none;
transition: var(--momo-transition-base);
}
.vendor-flow-card:hover {
color: var(--momo-text-primary);
border-color: var(--momo-border-strong);
transform: translateY(-1px);
}
.vendor-flow-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
color: var(--momo-accent-strong);
background: var(--momo-accent-soft);
border-radius: 6px;
}
.vendor-flow-title {
margin-top: 14px;
font-size: 14px;
font-weight: 800;
}
.vendor-flow-meta {
margin-top: 4px;
color: var(--momo-text-tertiary);
font-size: 11px;
line-height: 1.5;
}
.vendor-table-card {
overflow: hidden;
}
.vendor-table-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--momo-border-light);
}
.vendor-table-title {
color: var(--momo-text-primary);
font-size: 13px;
font-weight: 800;
}
.vendor-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
}
.vendor-summary-item {
min-height: 120px;
padding: 18px;
border-right: 1px solid var(--momo-border-light);
}
.vendor-summary-item:last-child {
border-right: 0;
}
.vendor-summary-title {
margin-top: 8px;
color: var(--momo-text-primary);
font-size: 16px;
font-weight: 800;
line-height: 1.4;
}
.vendor-summary-meta {
margin-top: 8px;
color: var(--momo-text-secondary);
font-size: 12px;
line-height: 1.7;
}
.vendor-empty {
padding: 28px;
color: var(--momo-text-secondary);
background: var(--momo-bg-paper);
border-top: 1px solid var(--momo-border-light);
font-size: 13px;
text-align: center;
}
@media (max-width: 1180px) {
.vendor-hero,
.vendor-kpi-grid,
.vendor-flow-grid,
.vendor-summary-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 720px) {
.vendor-hero,
.vendor-kpi-grid,
.vendor-flow-grid,
.vendor-summary-grid {
grid-template-columns: 1fr;
}
.vendor-title {
font-size: 28px;
}
.vendor-summary-item {
border-right: 0;
border-bottom: 1px solid var(--momo-border-light);
}
}
</style>
{% endblock %}
{% block ewooo_content %}
<div class="vendor-v2-stack">
<section class="vendor-hero" aria-label="廠商缺貨概況">
<div class="vendor-hero-panel">
<div>
<div class="vendor-eyebrow momo-mono">
<i class="fas fa-box-open"></i>
VENDOR STOCKOUT
</div>
<h1 class="vendor-title">廠商缺貨</h1>
<p class="vendor-subtitle">
以匯入的缺貨清單、廠商聯絡資料與郵件發送紀錄為準,追蹤待處理缺貨、已發送通知與聯絡資料完整度。
</p>
</div>
<div class="vendor-actions">
<a class="vendor-action is-primary" href="{{ url_for('vendor.import_page') }}">
<i class="fas fa-file-import"></i>
匯入 Excel
</a>
<a class="vendor-action" href="{{ url_for('vendor.list_page') }}">
<i class="fas fa-list-check"></i>
查看清單
</a>
<a class="vendor-action" href="{{ url_for('vendor.vendor_management_page') }}">
<i class="fas fa-address-book"></i>
廠商資料
</a>
</div>
</div>
<aside class="vendor-pulse">
<div>
<div class="vendor-pulse-label momo-mono">最新批次</div>
{% if stats.latest_batch_id %}
<div class="vendor-pulse-value momo-mono">{{ stats.latest_batch_count | number_format }}</div>
<div class="vendor-pulse-meta momo-mono">
批次 {{ stats.latest_batch_id }}<br>
{{ stats.latest_batch_time.strftime('%Y-%m-%d %H:%M') if stats.latest_batch_time else '沒有時間紀錄' }}
</div>
{% else %}
<div class="vendor-pulse-value momo-mono">0</div>
<div class="vendor-pulse-meta momo-mono">目前沒有匯入批次紀錄</div>
{% endif %}
</div>
</aside>
</section>
<section aria-label="廠商缺貨指標">
<div class="vendor-section-label">
<span class="num momo-mono">01</span>
<span class="title">即時指標</span>
</div>
<div class="vendor-kpi-grid">
<div class="vendor-card">
<div class="vendor-kpi-label momo-mono">缺貨總筆數</div>
<div class="vendor-kpi-value momo-mono">{{ stats.total_stockouts | number_format }}</div>
<div class="vendor-kpi-sub momo-mono">涵蓋 {{ stats.distinct_vendor_count | number_format }} 個來源廠商</div>
</div>
<div class="vendor-card">
<div class="vendor-kpi-label momo-mono">待發送</div>
<div class="vendor-kpi-value momo-mono is-danger">{{ stats.pending_stockouts | number_format }}</div>
<div class="vendor-kpi-sub momo-mono">失敗 {{ stats.failed_stockouts | number_format }} 筆</div>
</div>
<div class="vendor-card">
<div class="vendor-kpi-label momo-mono">已發送</div>
<div class="vendor-kpi-value momo-mono is-success">{{ stats.sent_stockouts | number_format }}</div>
<div class="vendor-kpi-sub momo-mono">重複 {{ stats.duplicate_stockouts | number_format }} 筆</div>
</div>
<div class="vendor-card">
<div class="vendor-kpi-label momo-mono">啟用廠商</div>
<div class="vendor-kpi-value momo-mono">{{ stats.active_vendors | number_format }}</div>
<div class="vendor-kpi-sub momo-mono">啟用郵件 {{ stats.active_emails | number_format }}</div>
</div>
</div>
</section>
<section aria-label="廠商缺貨工作流程">
<div class="vendor-section-label">
<span class="num momo-mono">02</span>
<span class="title">工作入口</span>
</div>
<div class="vendor-flow-grid">
<a class="vendor-flow-card" href="{{ url_for('vendor.import_page') }}">
<span class="vendor-flow-icon"><i class="fas fa-file-import"></i></span>
<span>
<span class="vendor-flow-title">Excel 匯入</span>
<span class="vendor-flow-meta momo-mono">建立缺貨批次</span>
</span>
</a>
<a class="vendor-flow-card" href="{{ url_for('vendor.list_page') }}">
<span class="vendor-flow-icon"><i class="fas fa-table-list"></i></span>
<span>
<span class="vendor-flow-title">缺貨清單</span>
<span class="vendor-flow-meta momo-mono">{{ stats.total_stockouts | number_format }} 筆記錄</span>
</span>
</a>
<a class="vendor-flow-card" href="{{ url_for('vendor.vendor_management_page') }}">
<span class="vendor-flow-icon"><i class="fas fa-address-card"></i></span>
<span>
<span class="vendor-flow-title">廠商管理</span>
<span class="vendor-flow-meta momo-mono">{{ stats.active_vendors | number_format }} 個啟用廠商</span>
</span>
</a>
<a class="vendor-flow-card" href="{{ url_for('vendor.send_email_page') }}">
<span class="vendor-flow-icon"><i class="fas fa-paper-plane"></i></span>
<span>
<span class="vendor-flow-title">郵件發送</span>
<span class="vendor-flow-meta momo-mono">{{ stats.pending_stockouts | number_format }} 筆待處理</span>
</span>
</a>
<a class="vendor-flow-card" href="{{ url_for('vendor.history_page') }}">
<span class="vendor-flow-icon"><i class="fas fa-clock-rotate-left"></i></span>
<span>
<span class="vendor-flow-title">發送歷史</span>
<span class="vendor-flow-meta momo-mono">{{ stats.email_total | number_format }} 筆郵件紀錄</span>
</span>
</a>
</div>
</section>
<section class="vendor-table-card" aria-label="資料狀態摘要">
<div class="vendor-table-head">
<span class="vendor-table-title">資料狀態摘要</span>
<span class="vendor-kpi-sub momo-mono">來源vendor_stockout / vendor_list / vendor_emails / email_send_log</span>
</div>
<div class="vendor-summary-grid">
<div class="vendor-summary-item">
<div class="vendor-card-label momo-mono">最新匯入</div>
{% if stats.latest_import_time %}
<div class="vendor-summary-title">{{ stats.latest_import_vendor or '未標記廠商' }}</div>
<div class="vendor-summary-meta momo-mono">
{{ stats.latest_import_time.strftime('%Y-%m-%d %H:%M') }}<br>
{{ stats.latest_import_product or '未標記商品' }}
</div>
{% else %}
<div class="vendor-summary-title">尚無匯入紀錄</div>
<div class="vendor-summary-meta momo-mono">資料庫目前沒有缺貨資料</div>
{% endif %}
</div>
<div class="vendor-summary-item">
<div class="vendor-card-label momo-mono">郵件成功率</div>
<div class="vendor-summary-title momo-mono">{{ stats.email_success_rate }}%</div>
<div class="vendor-summary-meta momo-mono">
成功 {{ stats.email_success | number_format }} / 總計 {{ stats.email_total | number_format }}<br>
失敗 {{ stats.email_failed | number_format }},待發送 {{ stats.email_pending | number_format }}
</div>
</div>
<div class="vendor-summary-item">
<div class="vendor-card-label momo-mono">最新郵件</div>
{% if stats.latest_email_time %}
<div class="vendor-summary-title momo-mono">{{ stats.latest_email_status or '未標記狀態' }}</div>
<div class="vendor-summary-meta momo-mono">
{{ stats.latest_email_time.strftime('%Y-%m-%d %H:%M') }}<br>
vendor_id {{ stats.latest_email_vendor_id }}
</div>
{% else %}
<div class="vendor-summary-title">尚無郵件紀錄</div>
<div class="vendor-summary-meta momo-mono">email_send_log 目前沒有資料</div>
{% endif %}
</div>
</div>
{% if stats.total_stockouts == 0 %}
<div class="vendor-empty momo-mono">
目前沒有匯入的缺貨資料;請先使用既有 Excel 匯入流程建立真實批次。
</div>
{% endif %}
</section>
</div>
{% endblock %}