feat(frontend): 新增廠商缺貨匯入 V2
All checks were successful
CD Pipeline / deploy (push) Successful in 1m42s

This commit is contained in:
OoO
2026-05-01 00:21:26 +08:00
parent f9fec4706e
commit 15b3bae9cb
7 changed files with 606 additions and 28 deletions

View File

@@ -2,7 +2,7 @@
> 本文件定義專案開發的核心準則與不可違反的規範
> **建立日期**: 2026-01-12
> **當前版本**: V10.38 (Vendor stockout list v2 feature flag)
> **當前版本**: V10.39 (Vendor stockout import v2 feature flag)
> **最後更新**: 2026-05-01
---

4
app.py
View File

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

View File

@@ -211,6 +211,11 @@ def index():
def import_page():
"""Excel 匯入頁面"""
sys_log.info("[VendorStockout] 進入匯入頁面")
if request.args.get('ui') == 'v2':
return render_template(
'vendor_stockout_import_v2.html',
active_page='vendor_stockout'
)
return render_template('vendor_stockout/import.html')
@@ -428,28 +433,12 @@ def api_import_excel():
def api_import_template():
"""下載 Excel 匯入範本(使用實際欄位名稱)"""
try:
# 建立範本 DataFrame使用實際的欄位名稱
template_data = {
'當前日期': ['2026-01-12'],
'處別': ['範例處別'],
'科別': ['範例科別'],
'PM姓名': ['王小明'],
'區ID': ['A01'],
'區名稱': ['範例區域'],
'商品ID': ['123456789'],
'商品名稱': ['範例商品名稱'],
'單品/組合商品': ['單品'],
'借採轉': [''],
'來源供應商編號': ['V001'],
'來源供應商名稱': ['範例供應商股份有限公司'],
'商品可賣量': [50],
'缺貨日期': ['2026-01-10'],
'缺貨天數': [2],
'缺貨商品前30天業績': [150000],
'最近30天銷售量': [300],
'庫存水位': ['']
}
df = pd.DataFrame(template_data)
template_columns = [
'當前日期', '處別', '科別', 'PM姓名', '區ID', '區名稱', '商品ID', '商品名稱',
'單品/組合商品', '借採轉', '來源供應商編號', '來源供應商名稱', '商品可賣量',
'缺貨日期', '缺貨天數', '缺貨商品前30天業績', '最近30天銷售量', '庫存水位'
]
df = pd.DataFrame(columns=template_columns)
# 輸出到記憶體
output = io.BytesIO()

View File

@@ -0,0 +1,574 @@
{% extends 'ewoooc_base.html' %}
{% block title %}EwoooC 缺貨匯入{% endblock %}
{% block extra_css %}
<style>
.stockout-import-stack {
display: grid;
gap: 22px;
}
.stockout-import-header,
.stockout-upload-panel,
.stockout-import-card {
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border-light);
border-radius: 8px;
}
.stockout-import-header {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.7fr);
gap: 18px;
padding: 22px;
}
.stockout-import-eyebrow {
display: inline-flex;
width: fit-content;
align-items: center;
gap: 7px;
margin-bottom: 10px;
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;
}
.stockout-import-title {
margin: 0;
color: var(--momo-text-primary);
font-size: 30px;
font-weight: 800;
letter-spacing: 0;
}
.stockout-import-subtitle {
max-width: 760px;
margin: 8px 0 0;
color: var(--momo-text-secondary);
font-size: 13px;
line-height: 1.7;
}
.stockout-import-actions,
.stockout-result-grid {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.stockout-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
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: 12px;
font-weight: 800;
text-decoration: none;
transition: var(--momo-transition-base);
}
.stockout-action:hover {
color: var(--momo-text-primary);
border-color: var(--momo-border-strong);
transform: translateY(-1px);
}
.stockout-action.is-primary {
color: var(--momo-text-inverse);
background: var(--momo-ink);
border-color: var(--momo-ink);
}
.stockout-import-note {
display: grid;
align-content: center;
gap: 8px;
padding: 16px;
color: var(--momo-text-inverse);
background: var(--momo-ink);
border-radius: 8px;
}
.stockout-import-note-label {
color: rgba(250, 247, 240, 0.62);
font-size: 11px;
font-weight: 800;
}
.stockout-import-note-value {
color: var(--momo-text-inverse);
font-size: 18px;
font-weight: 800;
line-height: 1.35;
}
.stockout-upload-panel {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.65fr);
gap: 0;
overflow: hidden;
}
.stockout-dropzone {
display: grid;
min-height: 330px;
cursor: pointer;
place-items: center;
padding: 30px;
background: var(--momo-bg-surface);
border: 0;
border-right: 1px solid var(--momo-border-light);
transition: var(--momo-transition-base);
}
.stockout-dropzone:hover,
.stockout-dropzone.is-dragover {
background: var(--momo-bg-paper);
}
.stockout-dropzone-inner {
max-width: 520px;
text-align: center;
}
.stockout-upload-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 58px;
height: 58px;
color: var(--momo-accent-strong);
background: var(--momo-accent-soft);
border-radius: 8px;
font-size: 24px;
}
.stockout-dropzone-title {
margin: 18px 0 8px;
color: var(--momo-text-primary);
font-size: 20px;
font-weight: 800;
}
.stockout-dropzone-subtitle {
margin: 0;
color: var(--momo-text-secondary);
font-size: 13px;
line-height: 1.7;
}
.stockout-import-side {
display: grid;
gap: 12px;
align-content: start;
padding: 18px;
background: var(--momo-bg-paper);
}
.stockout-import-card {
padding: 16px;
}
.stockout-card-label {
color: var(--momo-text-secondary);
font-size: 11px;
font-weight: 800;
}
.stockout-card-title {
margin-top: 8px;
color: var(--momo-text-primary);
font-size: 15px;
font-weight: 800;
line-height: 1.5;
}
.stockout-card-meta {
margin-top: 8px;
color: var(--momo-text-tertiary);
font-size: 12px;
line-height: 1.7;
}
.stockout-file-panel,
.stockout-progress-panel,
.stockout-result-panel,
.stockout-error-panel {
display: none;
padding: 18px;
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border-light);
border-radius: 8px;
}
.stockout-file-panel.is-visible,
.stockout-progress-panel.is-visible,
.stockout-result-panel.is-visible,
.stockout-error-panel.is-visible {
display: block;
}
.stockout-file-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.stockout-file-name {
color: var(--momo-text-primary);
font-size: 14px;
font-weight: 800;
word-break: break-word;
}
.stockout-file-size {
color: var(--momo-text-tertiary);
font-size: 11px;
white-space: nowrap;
}
.stockout-progress-track {
height: 8px;
overflow: hidden;
background: var(--momo-bg-paper);
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-pill);
}
.stockout-progress-bar {
width: 100%;
height: 100%;
background: var(--momo-accent);
animation: stockout-pulse 1.1s ease-in-out infinite alternate;
}
.stockout-result-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
}
.stockout-result-cell {
padding: 14px;
background: var(--momo-bg-paper);
border: 1px solid var(--momo-border-light);
border-radius: 6px;
}
.stockout-result-value {
color: var(--momo-text-primary);
font-size: 26px;
font-weight: 800;
line-height: 1;
}
.stockout-result-label {
margin-top: 8px;
color: var(--momo-text-secondary);
font-size: 11px;
}
.stockout-batch-id {
margin-top: 14px;
color: var(--momo-text-secondary);
font-size: 12px;
}
.stockout-error-panel {
color: var(--momo-danger);
background: rgba(214, 83, 68, 0.08);
border-color: rgba(214, 83, 68, 0.22);
}
@keyframes stockout-pulse {
from { opacity: 0.48; }
to { opacity: 1; }
}
@media (max-width: 980px) {
.stockout-import-header,
.stockout-upload-panel {
grid-template-columns: 1fr;
}
.stockout-dropzone {
border-right: 0;
border-bottom: 1px solid var(--momo-border-light);
}
}
@media (max-width: 720px) {
.stockout-import-title {
font-size: 26px;
}
.stockout-file-row {
flex-direction: column;
align-items: flex-start;
}
.stockout-result-grid {
grid-template-columns: 1fr 1fr;
}
}
</style>
{% endblock %}
{% block ewooo_content %}
<div class="stockout-import-stack">
<section class="stockout-import-header">
<div>
<div class="stockout-import-eyebrow momo-mono">
<i class="fas fa-file-import"></i>
STOCKOUT IMPORT
</div>
<h1 class="stockout-import-title">Excel 匯入</h1>
<p class="stockout-import-subtitle">
上傳正式缺貨 Excel 後,系統會使用既有匯入 API 寫入 vendor_stockout完成後直接回傳批次編號與成功、重複、失敗筆數。
</p>
<div class="stockout-import-actions mt-3">
<a class="stockout-action" href="{{ url_for('vendor.index', ui='v2') }}">
<i class="fas fa-arrow-left"></i>
回總覽
</a>
<a class="stockout-action" href="{{ url_for('vendor.list_page', ui='v2') }}">
<i class="fas fa-table-list"></i>
缺貨清單
</a>
</div>
</div>
<aside class="stockout-import-note">
<div class="stockout-import-note-label momo-mono">資料原則</div>
<div class="stockout-import-note-value">只匯入使用者上傳的正式 Excel不預填任何資料列。</div>
</aside>
</section>
<section class="stockout-upload-panel" aria-label="Excel 匯入">
<button class="stockout-dropzone" id="stockoutDropzone" type="button">
<input id="stockoutFileInput" type="file" accept=".xlsx,.xls" hidden>
<span class="stockout-dropzone-inner">
<span class="stockout-upload-icon">
<i class="fas fa-cloud-arrow-up"></i>
</span>
<span class="stockout-dropzone-title d-block">選擇或拖曳 Excel 檔案</span>
<span class="stockout-dropzone-subtitle d-block">
支援 .xlsx / .xls。檔案送出後會寫入正式缺貨資料表請確認內容是可匯入的正式清單。
</span>
<span class="stockout-action is-primary mt-3">
<i class="fas fa-folder-open"></i>
選擇檔案
</span>
</span>
</button>
<aside class="stockout-import-side">
<div class="stockout-import-card">
<div class="stockout-card-label momo-mono">必要欄位</div>
<div class="stockout-card-title">來源供應商編號、來源供應商名稱、商品ID、商品名稱</div>
<div class="stockout-card-meta momo-mono">缺少必要欄位時API 會拒絕匯入並回傳錯誤原因。</div>
</div>
<div class="stockout-import-card">
<div class="stockout-card-label momo-mono">欄位範本</div>
<div class="stockout-card-title">下載只有正式欄位標題的空白範本</div>
<div class="stockout-card-meta">
<a class="stockout-action" href="{{ url_for('vendor.api_import_template') }}">
<i class="fas fa-download"></i>
下載範本
</a>
</div>
</div>
</aside>
</section>
<section class="stockout-file-panel" id="stockoutFilePanel">
<div class="stockout-file-row">
<div>
<div class="stockout-card-label momo-mono">已選擇檔案</div>
<div class="stockout-file-name" id="stockoutFileName"></div>
<div class="stockout-file-size momo-mono" id="stockoutFileSize"></div>
</div>
<button class="stockout-action is-primary" id="stockoutUploadButton" type="button">
<i class="fas fa-upload"></i>
開始匯入
</button>
</div>
</section>
<section class="stockout-progress-panel" id="stockoutProgressPanel">
<div class="stockout-card-label momo-mono mb-2">匯入中</div>
<div class="stockout-progress-track">
<div class="stockout-progress-bar"></div>
</div>
</section>
<section class="stockout-result-panel" id="stockoutResultPanel">
<div class="stockout-card-label momo-mono">匯入結果</div>
<div class="stockout-result-grid">
<div class="stockout-result-cell">
<div class="stockout-result-value momo-mono" id="stockoutTotalCount">0</div>
<div class="stockout-result-label">總筆數</div>
</div>
<div class="stockout-result-cell">
<div class="stockout-result-value momo-mono" id="stockoutSuccessCount">0</div>
<div class="stockout-result-label">成功匯入</div>
</div>
<div class="stockout-result-cell">
<div class="stockout-result-value momo-mono" id="stockoutDuplicateCount">0</div>
<div class="stockout-result-label">重複項目</div>
</div>
<div class="stockout-result-cell">
<div class="stockout-result-value momo-mono" id="stockoutFailedCount">0</div>
<div class="stockout-result-label">失敗</div>
</div>
</div>
<div class="stockout-batch-id momo-mono">批次編號 <span id="stockoutBatchId"></span></div>
<div class="stockout-import-actions mt-3">
<a class="stockout-action is-primary" href="{{ url_for('vendor.list_page', ui='v2') }}">
<i class="fas fa-table-list"></i>
查看缺貨清單
</a>
<button class="stockout-action" id="stockoutImportAgainButton" type="button">
<i class="fas fa-rotate-right"></i>
再次匯入
</button>
</div>
</section>
<section class="stockout-error-panel" id="stockoutErrorPanel">
<div class="stockout-card-label momo-mono">匯入失敗</div>
<div class="stockout-card-title" id="stockoutErrorMessage"></div>
</section>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
const dropzone = document.getElementById('stockoutDropzone');
const fileInput = document.getElementById('stockoutFileInput');
const filePanel = document.getElementById('stockoutFilePanel');
const fileName = document.getElementById('stockoutFileName');
const fileSize = document.getElementById('stockoutFileSize');
const uploadButton = document.getElementById('stockoutUploadButton');
const progressPanel = document.getElementById('stockoutProgressPanel');
const resultPanel = document.getElementById('stockoutResultPanel');
const errorPanel = document.getElementById('stockoutErrorPanel');
const errorMessage = document.getElementById('stockoutErrorMessage');
const importAgainButton = document.getElementById('stockoutImportAgainButton');
let selectedFile = null;
function show(panel) {
panel.classList.add('is-visible');
}
function hide(panel) {
panel.classList.remove('is-visible');
}
function resetTransientPanels() {
hide(progressPanel);
hide(resultPanel);
hide(errorPanel);
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function handleFile(file) {
if (!file) return;
if (!file.name.match(/\.(xlsx|xls)$/i)) {
errorMessage.textContent = '請選擇 Excel 檔案 (.xlsx 或 .xls)';
show(errorPanel);
return;
}
selectedFile = file;
fileName.textContent = file.name;
fileSize.textContent = formatFileSize(file.size);
resetTransientPanels();
show(filePanel);
}
dropzone.addEventListener('click', function () {
fileInput.click();
});
fileInput.addEventListener('change', function (event) {
handleFile(event.target.files[0]);
});
dropzone.addEventListener('dragover', function (event) {
event.preventDefault();
dropzone.classList.add('is-dragover');
});
dropzone.addEventListener('dragleave', function () {
dropzone.classList.remove('is-dragover');
});
dropzone.addEventListener('drop', function (event) {
event.preventDefault();
dropzone.classList.remove('is-dragover');
handleFile(event.dataTransfer.files[0]);
});
uploadButton.addEventListener('click', async function () {
if (!selectedFile) return;
const formData = new FormData();
formData.append('file', selectedFile);
hide(filePanel);
hide(resultPanel);
hide(errorPanel);
show(progressPanel);
try {
const response = await fetchWithCSRF('/vendor-stockout/api/import/excel', {
method: 'POST',
body: formData
});
const result = await response.json();
hide(progressPanel);
if (result.success) {
document.getElementById('stockoutTotalCount').textContent = result.data.total_count;
document.getElementById('stockoutSuccessCount').textContent = result.data.success_count;
document.getElementById('stockoutDuplicateCount').textContent = result.data.duplicate_count;
document.getElementById('stockoutFailedCount').textContent = result.data.failed_count;
document.getElementById('stockoutBatchId').textContent = result.data.batch_id;
show(resultPanel);
} else {
errorMessage.textContent = result.message || '匯入失敗';
show(errorPanel);
}
} catch (error) {
hide(progressPanel);
errorMessage.textContent = '網路錯誤:' + error.message;
show(errorPanel);
}
});
importAgainButton.addEventListener('click', function () {
selectedFile = null;
fileInput.value = '';
hide(filePanel);
resetTransientPanels();
});
})();
</script>
{% endblock %}

View File

@@ -341,7 +341,7 @@
</p>
</div>
<div class="vendor-actions">
<a class="vendor-action is-primary" href="{{ url_for('vendor.import_page') }}">
<a class="vendor-action is-primary" href="{{ url_for('vendor.import_page', ui='v2') }}">
<i class="fas fa-file-import"></i>
匯入 Excel
</a>
@@ -408,7 +408,7 @@
<span class="title">工作入口</span>
</div>
<div class="vendor-flow-grid">
<a class="vendor-flow-card" href="{{ url_for('vendor.import_page') }}">
<a class="vendor-flow-card" href="{{ url_for('vendor.import_page', ui='v2') }}">
<span class="vendor-flow-icon"><i class="fas fa-file-import"></i></span>
<span>
<span class="vendor-flow-title">Excel 匯入</span>

View File

@@ -334,7 +334,7 @@
<i class="fas fa-arrow-left"></i>
回總覽
</a>
<a class="stockout-action is-primary" href="{{ url_for('vendor.import_page') }}">
<a class="stockout-action is-primary" href="{{ url_for('vendor.import_page', ui='v2') }}">
<i class="fas fa-file-import"></i>
匯入 Excel
</a>

View File

@@ -99,3 +99,18 @@ def test_vendor_stockout_list_v2_is_feature_flagged_and_uses_real_stockout_rows(
assert "record.vendor_name" in template
assert "mock" not in template.lower()
assert "" not in template
def test_vendor_stockout_import_v2_is_feature_flagged_and_does_not_ship_sample_rows():
route_source = (ROOT / "routes/vendor_routes.py").read_text(encoding="utf-8")
template = (ROOT / "templates/vendor_stockout_import_v2.html").read_text(encoding="utf-8")
template_function = route_source.split("def api_import_template():", 1)[1].split("@vendor_bp.route('/api/stockout/list'", 1)[0]
assert "vendor_stockout_import_v2.html" in route_source
assert "template_columns = [" in template_function
assert "pd.DataFrame(columns=template_columns)" in template_function
assert "fetchWithCSRF('/vendor-stockout/api/import/excel'" in template
assert "vendor_stockout" in template
assert "範例" not in template_function
assert "mock" not in template.lower()
assert "" not in template