From 99d5bc8e81ecc6bb11fea6c4f209cc234d9f7380 Mon Sep 17 00:00:00 2001 From: OoO Date: Fri, 1 May 2026 00:12:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E6=96=B0=E5=A2=9E=E5=BB=A0?= =?UTF-8?q?=E5=95=86=E7=BC=BA=E8=B2=A8=E6=B8=85=E5=96=AE=20V2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONSTITUTION.md | 2 +- app.py | 4 +- routes/vendor_routes.py | 102 +++++ templates/vendor_stockout_index_v2.html | 4 +- templates/vendor_stockout_list_v2.html | 484 ++++++++++++++++++++ tests/test_frontend_v2_assets.py | 27 +- web/templates/vendor_stockout_index_v2.html | 246 ---------- 7 files changed, 615 insertions(+), 254 deletions(-) create mode 100644 templates/vendor_stockout_list_v2.html delete mode 100644 web/templates/vendor_stockout_index_v2.html diff --git a/CONSTITUTION.md b/CONSTITUTION.md index 344564f..59695e2 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -2,7 +2,7 @@ > 本文件定義專案開發的核心準則與不可違反的規範 > **建立日期**: 2026-01-12 -> **當前版本**: V10.37 (Vendor stockout v2 feature flag) +> **當前版本**: V10.38 (Vendor stockout list v2 feature flag) > **最後更新**: 2026-05-01 --- diff --git a/app.py b/app.py index 9691c37..2b2a944 100644 --- a/app.py +++ b/app.py @@ -95,8 +95,8 @@ except Exception as e: sys_log.error(f"無法檢測磁碟空間: {e}") # 🚩 系統版本定義 (備份與顯示用) -# 🚩 2026-05-01 V10.37: Vendor stockout v2 feature flag -SYSTEM_VERSION = "V10.37" +# 🚩 2026-05-01 V10.38: Vendor stockout list v2 feature flag +SYSTEM_VERSION = "V10.38" # ========================================== # 🔒 SQL Injection 防護函數 diff --git a/routes/vendor_routes.py b/routes/vendor_routes.py index 90423d5..cc56f5c 100644 --- a/routes/vendor_routes.py +++ b/routes/vendor_routes.py @@ -98,6 +98,102 @@ def _get_vendor_dashboard_stats(): session.close() +def _apply_stockout_filters(query, batch_id=None, vendor_keyword=None): + if batch_id: + query = query.filter(VendorStockout.batch_id == batch_id) + + if vendor_keyword: + query = query.filter(or_( + VendorStockout.vendor_code.like(f'%{vendor_keyword}%'), + VendorStockout.vendor_name.like(f'%{vendor_keyword}%'), + VendorStockout.product_code.like(f'%{vendor_keyword}%'), + VendorStockout.product_name.like(f'%{vendor_keyword}%') + )) + + return query + + +def _get_vendor_stockout_list_context(): + """缺貨清單 V2:以資料庫真實資料組裝篩選、分頁與列表。""" + session = vendor_db.get_session() + try: + page = max(request.args.get('page', 1, type=int), 1) + page_size = min(max(request.args.get('page_size', 30, type=int), 10), 100) + status_filter = (request.args.get('status') or 'all').strip() + batch_filter = (request.args.get('batch') or '').strip() + vendor_keyword = (request.args.get('q') or '').strip() + sort_by = (request.args.get('sort') or 'created_desc').strip() + + filtered_base = _apply_stockout_filters( + session.query(VendorStockout), + batch_filter, + vendor_keyword + ) + + query = filtered_base + if status_filter == 'pending': + query = query.filter(or_( + VendorStockout.status == 'pending', + VendorStockout.status.is_(None) + )) + elif status_filter in ['sent', 'failed']: + query = query.filter(VendorStockout.status == status_filter) + elif status_filter == 'duplicate': + query = query.filter(VendorStockout.is_duplicate.is_(True)) + else: + status_filter = 'all' + + if sort_by == 'created_asc': + query = query.order_by(VendorStockout.created_at.asc()) + elif sort_by == 'vendor_asc': + query = query.order_by(VendorStockout.vendor_code.asc(), VendorStockout.created_at.desc()) + elif sort_by == 'stockout_days_desc': + query = query.order_by(VendorStockout.stockout_days.desc().nullslast(), VendorStockout.created_at.desc()) + else: + sort_by = 'created_desc' + query = query.order_by(VendorStockout.created_at.desc()) + + total_items = query.count() + total_pages = max((total_items + page_size - 1) // page_size, 1) + if page > total_pages: + page = total_pages + + records = query.offset((page - 1) * page_size).limit(page_size).all() + + latest_batch_time = func.max(VendorStockout.created_at).label('latest_date') + batches = session.query( + VendorStockout.batch_id, + func.count(VendorStockout.id).label('record_count'), + latest_batch_time + ).group_by(VendorStockout.batch_id).order_by(desc(latest_batch_time)).limit(30).all() + + return { + 'records': records, + 'batches': batches, + 'total_items': total_items, + 'total_pages': total_pages, + 'current_page': page, + 'page_size': page_size, + 'current_status': status_filter, + 'current_batch': batch_filter, + 'search_query': vendor_keyword, + 'current_sort': sort_by, + 'stats': { + 'total': filtered_base.count(), + 'pending': filtered_base.filter(or_( + VendorStockout.status == 'pending', + VendorStockout.status.is_(None) + )).count(), + 'sent': filtered_base.filter(VendorStockout.status == 'sent').count(), + 'failed': filtered_base.filter(VendorStockout.status == 'failed').count(), + 'duplicate': filtered_base.filter(VendorStockout.is_duplicate.is_(True)).count(), + 'vendor_count': filtered_base.with_entities(VendorStockout.vendor_code).distinct().count(), + } + } + finally: + session.close() + + @vendor_bp.route('/') def index(): """廠商缺貨系統主頁""" @@ -122,6 +218,12 @@ def import_page(): def list_page(): """缺貨清單頁面""" sys_log.info("[VendorStockout] 進入缺貨清單頁面") + if request.args.get('ui') == 'v2': + return render_template( + 'vendor_stockout_list_v2.html', + active_page='vendor_stockout', + **_get_vendor_stockout_list_context() + ) return render_template('vendor_stockout/list.html') diff --git a/templates/vendor_stockout_index_v2.html b/templates/vendor_stockout_index_v2.html index 917f32b..226eea1 100644 --- a/templates/vendor_stockout_index_v2.html +++ b/templates/vendor_stockout_index_v2.html @@ -345,7 +345,7 @@ 匯入 Excel - + 查看清單 @@ -415,7 +415,7 @@ 建立缺貨批次 - + 缺貨清單 diff --git a/templates/vendor_stockout_list_v2.html b/templates/vendor_stockout_list_v2.html new file mode 100644 index 0000000..0f1cb01 --- /dev/null +++ b/templates/vendor_stockout_list_v2.html @@ -0,0 +1,484 @@ +{% extends 'ewoooc_base.html' %} + +{% block title %}EwoooC 缺貨清單{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block ewooo_content %} +{% set status_labels = {'pending': '待發送', 'sent': '已發送', 'failed': '失敗'} %} +
+
+
+
+ + STOCKOUT LIST +
+

缺貨清單

+

+ 依現有缺貨匯入資料呈現,可用批次、廠商、商品與發送狀態篩選;所有數字皆來自 vendor_stockout。 +

+
+
+
+ +
+
+
符合筆數
+
{{ stats.total | number_format }}
+
目前清單 {{ total_items | number_format }} 筆
+
+
+
待發送
+
{{ stats.pending | number_format }}
+
含未標記狀態
+
+
+
已發送
+
{{ stats.sent | number_format }}
+
通知完成記錄
+
+
+
失敗
+
{{ stats.failed | number_format }}
+
需人工檢查
+
+
+
來源廠商
+
{{ stats.vendor_count | number_format }}
+
重複 {{ stats.duplicate | number_format }} 筆
+
+
+ +
+
+ + + + + +
+
+ + + +
+
+
+ 缺貨資料 + 第 {{ current_page }} / {{ total_pages }} 頁 +
+ {{ total_items | number_format }} 筆 +
+ + {% if records %} +
+ + + + + + + + + + + + + + + + {% for record in records %} + {% set record_status = record.status or 'pending' %} + + + + + + + + + + + + {% endfor %} + +
狀態商品廠商批次庫存缺貨日期缺貨天數30 日業績建立時間
+ + {{ status_labels.get(record_status, record_status) }} + + {% if record.is_duplicate %} +
重複 {{ record.duplicate_count or 0 }}
+ {% endif %} +
+
{{ record.product_name }}
+
{{ record.product_code }}
+
+
{{ record.vendor_name }}
+
{{ record.vendor_code }}
+
{{ record.batch_id }}{{ record.current_stock if record.current_stock is not none else '--' }}{{ record.stockout_date.strftime('%Y-%m-%d') if record.stockout_date else '--' }}{{ record.stockout_days if record.stockout_days is not none else '--' }} + {% if record.monthly_sales_amount is not none %} + ${{ record.monthly_sales_amount | int | number_format }} + {% else %} + -- + {% endif %} + {{ record.created_at.strftime('%Y-%m-%d %H:%M') if record.created_at else '--' }}
+
+ {% else %} +
+ 目前沒有符合條件的缺貨資料;請調整篩選條件,或先匯入真實缺貨清單。 +
+ {% endif %} + +
+ {% if current_page > 1 %} + 上一頁 + {% endif %} + {{ current_page }} / {{ total_pages }} + {% if current_page < total_pages %} + 下一頁 + {% endif %} +
+
+
+{% endblock %} diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index 0f92ecf..d8edfa5 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -67,14 +67,35 @@ def test_edm_dashboard_v2_is_feature_flagged_and_uses_real_campaign_data(): def test_vendor_stockout_v2_is_feature_flagged_and_uses_real_vendor_data(): route_source = (ROOT / "routes/vendor_routes.py").read_text(encoding="utf-8") - template = (ROOT / "web/templates/vendor_stockout_index_v2.html").read_text(encoding="utf-8") + template = (ROOT / "templates/vendor_stockout_index_v2.html").read_text(encoding="utf-8") assert "request.args.get('ui') == 'v2'" in route_source + assert not (ROOT / "web/templates/vendor_stockout_index_v2.html").exists() assert "vendor_stockout_index_v2.html" in route_source assert "_get_vendor_dashboard_stats()" in route_source assert "session.query(VendorStockout).count()" in route_source assert "EmailSendLog" in route_source - assert "stats.get('pending_stockouts'" in template - assert "stats.get('email_success_rate'" in template + assert "stats.pending_stockouts" in template + assert "stats.email_success_rate" in template + assert "mock" not in template.lower() + assert "假" not in template + + +def test_vendor_stockout_list_v2_is_feature_flagged_and_uses_real_stockout_rows(): + route_source = (ROOT / "routes/vendor_routes.py").read_text(encoding="utf-8") + template = (ROOT / "templates/vendor_stockout_list_v2.html").read_text(encoding="utf-8") + + assert "vendor_stockout_list_v2.html" in route_source + assert "_get_vendor_stockout_list_context()" in route_source + assert "_apply_stockout_filters(" in route_source + assert "session.query(VendorStockout)" in route_source + assert "VendorStockout.status == 'pending'" in route_source + assert "VendorStockout.batch_id == batch_id" in route_source + assert "{% for record in records %}" in template + assert "{% for batch in batches %}" in template + assert "stats.pending" in template + assert "record.product_code" in template + assert "record.product_name" in template + assert "record.vendor_name" in template assert "mock" not in template.lower() assert "假" not in template diff --git a/web/templates/vendor_stockout_index_v2.html b/web/templates/vendor_stockout_index_v2.html deleted file mode 100644 index 1f984cd..0000000 --- a/web/templates/vendor_stockout_index_v2.html +++ /dev/null @@ -1,246 +0,0 @@ -{% extends 'ewoooc_base.html' %} - -{% block title %}EwoooC 廠商缺貨{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block ewooo_content %} -{% set stats = stats or {} %} -
-
-
-
- OPERATIONS - Vendor Stockout Control -
-

廠商缺貨通知自動化系統

-

- 串接正式缺貨匯入、廠商郵件與發送紀錄資料,集中追蹤待處理缺貨、通知成功率、廠商聯絡覆蓋與最近批次狀態。 -

- -
- - -
- -
- -
-
-
-
匯入缺貨資料
-
使用正式 Excel 匯入流程,建立批次缺貨紀錄並標記重複資料。
- 前往匯入 -
-
-
-
缺貨清單
-
篩選、編輯、批次發信與追蹤缺貨商品處理狀態。
- 查看清單 -
-
-
-
廠商管理
-
維護廠商主檔與多組收件、CC、BCC 郵件地址。
- 管理廠商 -
-
-
-
發送歷史
-
檢查成功、失敗、待發送郵件紀錄與錯誤訊息。
- 查看歷史 -
-
-
- -
- -
-
-
最近匯入
-
- {% if stats.get('latest_import_time') %} -
時間{{ stats.get('latest_import_time').strftime('%Y-%m-%d %H:%M') }}
-
廠商{{ stats.get('latest_import_vendor') or '未提供' }}
-
商品{{ stats.get('latest_import_product') or '未提供' }}
- {% else %} - 尚未讀到缺貨匯入紀錄。 - {% endif %} -
-
-
-
最近批次
-
- {% if stats.get('latest_batch_id') %} -
批次{{ stats.get('latest_batch_id') }}
-
筆數{{ stats.get('latest_batch_count', 0) | number_format }}
-
時間{{ stats.get('latest_batch_time').strftime('%Y-%m-%d %H:%M') if stats.get('latest_batch_time') else '--' }}
- {% else %} - 尚未讀到批次資訊。 - {% endif %} -
-
-
-
郵件狀態
-
-
成功{{ stats.get('email_success', 0) | number_format }}
-
失敗{{ stats.get('email_failed', 0) | number_format }}
-
待發送{{ stats.get('email_pending', 0) | number_format }}
-
活躍 Email{{ stats.get('active_emails', 0) | number_format }}
-
-
-
-
-
-{% endblock %}