refactor(vendor): 抽出廠商管理查詢服務
Some checks failed
CD Pipeline / deploy (push) Failing after 8m57s

This commit is contained in:
OoO
2026-05-01 14:20:09 +08:00
parent 1012d609d4
commit ea15aa6437
8 changed files with 135 additions and 101 deletions

View File

@@ -2,7 +2,7 @@
> 本文件定義專案開發的核心準則與不可違反的規範
> **建立日期**: 2026-01-12
> **當前版本**: V10.53 (Dashboard competitor decision overview)
> **當前版本**: V10.54 (Vendor management query service extraction)
> **最後更新**: 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.53: Dashboard competitor decision overview
SYSTEM_VERSION = "V10.53"
# 🚩 2026-05-01 V10.54: Vendor management query service extraction
SYSTEM_VERSION = "V10.54"
# ==========================================
# 🔒 SQL Injection 防護函數

View File

@@ -254,7 +254,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.53"
SYSTEM_VERSION = "V10.54"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -4,10 +4,10 @@
## 盤點結論
- Python 總量:約 66,719 行。
- 最大壓力區:`routes/` 約 20,928 行、`services/` 約 25,929 行。
- Python 總量:約 66,997 行。
- 最大壓力區:`routes/` 約 21,095 行、`services/` 約 26,023 行。
- `app.py` 目前約 1,209 行,功能定位應固定為 bootstrap / Blueprint registration / startup guard不再承接新 route。
- 目前仍有 15 個 Python 檔案超過 800 行;這些不是禁止修 bug而是禁止繼續塞新功能。
- 目前工作樹仍有 16 個 Python 檔案超過 800 行;這些不是禁止修 bug而是禁止繼續塞新功能。
## 超過 800 行檔案清單
@@ -17,12 +17,13 @@
| 2707 | `scheduler.py` | P0 排程總管 | task registry / crawler jobs / report jobs / notification jobs |
| 2653 | `routes/sales_routes.py` | P0 巨型 Blueprint | page routes / API routes / chart query service / calendar service |
| 1743 | `routes/ai_routes.py` | P1 AI Blueprint | route glue / AI orchestration service / prompt builders |
| 1565 | `routes/vendor_routes.py` | P1 Vendor Blueprint | route glue / stockout mutation/emailV2 page querystockout list/batches API query 已抽到 `services/vendor_stockout_query_service.py` |
| 1485 | `routes/vendor_routes.py` | P1 Vendor Blueprint | route glue / stockout mutation/emailV2 page querystockout list/batches API query、vendor list/detail query 已抽到 `services/vendor_stockout_query_service.py` |
| 1345 | `services/ppt_generator.py` | P1 報表生成 service | deck orchestration / slide builders / chart builders |
| 1339 | `services/nemoton_dispatcher_service.py` | P1 NemoTron service | NIM client / tool-call parser / action dispatcher |
| 1300 | `services/openclaw_strategist_service.py` | P1 OpenClaw service | prompt builders / report composer / strategy rules |
| 1209 | `app.py` | P1 bootstrap | 保持只做 app setup繼續往 app_factory / extension setup 抽 |
| 1079 | `routes/cicd_routes.py` | P2 CI/CD Blueprint | route glue / CI query service / deployment action service |
| 1024 | `routes/dashboard_routes.py` | P2 Dashboard Blueprint | competitor decision overview / dashboard query service目前工作樹已有未提交大段新增邏輯 |
| 986 | `services/telegram_bot_service.py` | P2 Telegram service | command handlers / message formatters / bot client |
| 966 | `services/trend_crawler.py` | P2 crawler service | source adapters / parser / persistence |
| 946 | `services/elephant_alpha_autonomous_engine.py` | P2 ElephantAlpha engine | HITL / executor / planning policy |
@@ -34,7 +35,7 @@
1. P0持續拆 `routes/openclaw_bot_routes.py`Telegram API helper 已搬到 `services/openclaw_bot/telegram_api.py`Inline Keyboard builders 已搬到 `services/openclaw_bot/menu_keyboards.py`,下一步拆 report formatting 或 command dispatcher。
2. P0`routes/sales_routes.py`,先把 chart/query/calendar 計算搬到 `services/sales/`
3. P0`scheduler.py`,建立 `jobs/``services/scheduler/` task registry。
4. P1`routes/ai_routes.py``routes/vendor_routes.py` 的資料處理移出 routeVendor V2 page query API list/batches 已完成,下一步可抽 email grouping 或 vendor management query
4. P1`routes/ai_routes.py``routes/vendor_routes.py` 的資料處理移出 routeVendor V2 page query、stockout API list/batches、vendor list/detail 已完成,下一步可抽 email grouping 或 vendor mutation service
5. P1把 PPT / NemoTron / OpenClaw 大 service 拆成 client、parser、composer、policy。
6. P2對 800-1100 行檔案採「碰到就順手抽」策略,但不可讓淨行數繼續增加。

View File

@@ -65,6 +65,7 @@
- **Vendor Stockout List V2 production default**: `/vendor-stockout/list` 預設渲染 `vendor_stockout_list_v2.html``?ui=legacy` 才回舊頁;清單、批次、搜尋、分頁與狀態統計都從正式 `VendorStockout` 查詢組裝,並移除重複的 `web/templates/vendor_stockout_index_v2.html`
- **Vendor query service extraction**: Vendor V2 首頁統計與缺貨清單 query 移到 `services/vendor_stockout_query_service.py``routes/vendor_routes.py` 由約 1,821 行降至 1,675 行,回到 request parsing + template rendering避免巨型 Blueprint 繼續承接資料組裝邏輯。
- **Vendor stockout API query extraction**: `/vendor-stockout/api/stockout/list``/vendor-stockout/api/stockout/batches` 的 query/serialization 移入同一個 `services/vendor_stockout_query_service.py`,保留既有 JSON shape`routes/vendor_routes.py` 再降至約 1,565 行。
- **Vendor management query extraction**: `/vendor-stockout/api/vendor/list``/vendor-stockout/api/vendor/<vendor_code>` 的 query/serialization 移入 `services/vendor_stockout_query_service.py`,保留既有 JSON shape`routes/vendor_routes.py` 再降至約 1,485 行。
### 2026-04-28~29Phase 3e 重構大戰 + daily_sales cache 隱形 bug 根除
- **app.py 縮減 -10.8%**: 7,386 → 6,590 行11 commits 全綠零 502。

View File

@@ -21,7 +21,9 @@ from services.logger_manager import SystemLogger
from services.vendor_stockout_query_service import (
get_stockout_api_list_payload,
get_stockout_batches_payload,
get_vendor_detail_payload,
get_vendor_dashboard_stats,
get_vendor_list_payload,
get_vendor_stockout_list_context,
)
@@ -522,73 +524,15 @@ def api_batch_mark_sent():
def api_get_vendor_list():
"""查詢廠商清單(支援分頁與搜尋)"""
try:
session = vendor_db.get_session()
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 20, type=int)
search = request.args.get('search', '').strip()
active_only = request.args.get('active_only', 'true').lower() == 'true'
# 建立查詢
query = session.query(VendorList)
if active_only:
query = query.filter(VendorList.is_active == True)
if search:
query = query.filter(
or_(
VendorList.vendor_code.like(f'%{search}%'),
VendorList.vendor_name.like(f'%{search}%')
)
)
# 計算總數
total = query.count()
# 排序與分頁
query = query.order_by(VendorList.vendor_code.asc())
offset = (page - 1) * page_size
vendors = query.offset(offset).limit(page_size).all()
# 組裝資料
vendors_data = []
for vendor in vendors:
# 取得廠商的郵件清單
emails_query = session.query(VendorEmail).filter(
VendorEmail.vendor_id == vendor.id,
VendorEmail.is_active == True
).all()
emails = [e.email for e in emails_query]
vendors_data.append({
'id': vendor.id,
'vendor_code': vendor.vendor_code,
'vendor_name': vendor.vendor_name,
'is_active': vendor.is_active,
'email_count': len(emails),
'emails': emails,
'created_at': vendor.created_at.isoformat() if vendor.created_at else None
})
# 計算統計數據
total_vendors = session.query(VendorList).filter(VendorList.is_active == True).count()
total_emails = session.query(VendorEmail).filter(VendorEmail.is_active == True).count()
avg_emails = round(total_emails / total_vendors, 1) if total_vendors > 0 else 0
return jsonify({
'success': True,
'data': {
'vendors': vendors_data,
'total': total,
'page': page,
'page_size': page_size,
'stats': {
'total_vendors': total_vendors,
'total_emails': total_emails,
'avg_emails': avg_emails
}
}
'data': get_vendor_list_payload(
vendor_db,
page=request.args.get('page', 1, type=int),
page_size=request.args.get('page_size', 20, type=int),
search=request.args.get('search', ''),
active_only=request.args.get('active_only', 'true').lower() == 'true'
)
})
except Exception as e:
@@ -596,9 +540,6 @@ def api_get_vendor_list():
import traceback
traceback.print_exc()
return jsonify({'success': False, 'message': f'查詢失敗: {str(e)}'}), 500
finally:
if 'session' in locals():
session.close()
@vendor_bp.route('/api/vendor', methods=['POST'])
@@ -671,39 +612,18 @@ def api_add_vendor():
def api_get_vendor(vendor_code):
"""取得單一廠商詳細資訊"""
try:
session = vendor_db.get_session()
vendor = session.query(VendorList).filter_by(vendor_code=vendor_code).first()
if not vendor:
payload = get_vendor_detail_payload(vendor_db, vendor_code)
if not payload:
return jsonify({'success': False, 'message': '廠商不存在'}), 404
# 取得郵件清單
emails_query = session.query(VendorEmail).filter(
VendorEmail.vendor_id == vendor.id,
VendorEmail.is_active == True
).all()
emails = [e.email for e in emails_query]
return jsonify({
'success': True,
'data': {
'id': vendor.id,
'vendor_code': vendor.vendor_code,
'vendor_name': vendor.vendor_name,
'is_active': vendor.is_active,
'emails': emails,
'email_count': len(emails),
'created_at': vendor.created_at.isoformat() if vendor.created_at else None
}
'data': payload
})
except Exception as e:
sys_log.error(f"[API] 查詢廠商失敗 | 代碼: {vendor_code} | 錯誤: {e}")
return jsonify({'success': False, 'message': f'查詢失敗: {str(e)}'}), 500
finally:
if 'session' in locals():
session.close()
@vendor_bp.route('/api/vendor/<vendor_code>', methods=['PUT'])

View File

@@ -298,3 +298,97 @@ def get_stockout_batches_payload(vendor_db):
]
finally:
session.close()
def _serialize_vendor(vendor, emails):
return {
'id': vendor.id,
'vendor_code': vendor.vendor_code,
'vendor_name': vendor.vendor_name,
'is_active': vendor.is_active,
'email_count': len(emails),
'emails': emails,
'created_at': vendor.created_at.isoformat() if vendor.created_at else None
}
def get_vendor_list_payload(
vendor_db,
page=1,
page_size=20,
search='',
active_only=True,
):
"""回傳舊版廠商清單 API 相容 payload。"""
page = page or 1
page_size = page_size or 20
search = (search or '').strip()
session = vendor_db.get_session()
try:
query = session.query(VendorList)
if active_only:
query = query.filter(VendorList.is_active.is_(True))
if search:
query = query.filter(or_(
VendorList.vendor_code.like(f'%{search}%'),
VendorList.vendor_name.like(f'%{search}%')
))
total = query.count()
vendors = query.order_by(VendorList.vendor_code.asc())\
.offset((page - 1) * page_size)\
.limit(page_size)\
.all()
vendors_data = []
for vendor in vendors:
emails = [
email.email
for email in session.query(VendorEmail).filter(
VendorEmail.vendor_id == vendor.id,
VendorEmail.is_active.is_(True)
).all()
]
vendors_data.append(_serialize_vendor(vendor, emails))
total_vendors = session.query(VendorList).filter(VendorList.is_active.is_(True)).count()
total_emails = session.query(VendorEmail).filter(VendorEmail.is_active.is_(True)).count()
avg_emails = round(total_emails / total_vendors, 1) if total_vendors > 0 else 0
return {
'vendors': vendors_data,
'total': total,
'page': page,
'page_size': page_size,
'stats': {
'total_vendors': total_vendors,
'total_emails': total_emails,
'avg_emails': avg_emails
}
}
finally:
session.close()
def get_vendor_detail_payload(vendor_db, vendor_code):
"""回傳單一廠商 API 相容 payload找不到時回傳 None。"""
session = vendor_db.get_session()
try:
vendor = session.query(VendorList).filter_by(vendor_code=vendor_code).first()
if not vendor:
return None
emails = [
email.email
for email in session.query(VendorEmail).filter(
VendorEmail.vendor_id == vendor.id,
VendorEmail.is_active.is_(True)
).all()
]
return _serialize_vendor(vendor, emails)
finally:
session.close()

View File

@@ -281,6 +281,24 @@ def test_vendor_stockout_api_queries_are_extracted_to_service():
assert "from flask" not in service_source
def test_vendor_management_queries_are_extracted_to_service():
route_source = (ROOT / "routes/vendor_routes.py").read_text(encoding="utf-8")
service_source = (ROOT / "services/vendor_stockout_query_service.py").read_text(encoding="utf-8")
api_section = route_source.split("def api_get_vendor_list():", 1)[1].split("@vendor_bp.route('/api/vendor', methods=['POST'])", 1)[0]
detail_section = route_source.split("def api_get_vendor(vendor_code):", 1)[1].split("@vendor_bp.route('/api/vendor/<vendor_code>', methods=['PUT'])", 1)[0]
assert "get_vendor_list_payload(" in api_section
assert "get_vendor_detail_payload(vendor_db, vendor_code)" in detail_section
assert "session.query(VendorList)" not in api_section
assert "session.query(VendorEmail)" not in detail_section
assert "def get_vendor_list_payload(" in service_source
assert "def get_vendor_detail_payload(vendor_db, vendor_code)" in service_source
assert "'vendor_code': vendor.vendor_code" in service_source
assert "'email_count': len(emails)" in service_source
assert "from flask" not in service_source
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")