This commit is contained in:
@@ -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
4
app.py
@@ -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 防護函數
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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/email;V2 page query 與 stockout list/batches API query 已抽到 `services/vendor_stockout_query_service.py` |
|
||||
| 1485 | `routes/vendor_routes.py` | P1 Vendor Blueprint | route glue / stockout mutation/email;V2 page query、stockout 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` 的資料處理移出 route;Vendor V2 page query 與 API list/batches 已完成,下一步可抽 email grouping 或 vendor management query。
|
||||
4. P1:把 `routes/ai_routes.py` 與 `routes/vendor_routes.py` 的資料處理移出 route;Vendor 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 行檔案採「碰到就順手抽」策略,但不可讓淨行數繼續增加。
|
||||
|
||||
|
||||
@@ -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~29:Phase 3e 重構大戰 + daily_sales cache 隱形 bug 根除
|
||||
- **app.py 縮減 -10.8%**: 7,386 → 6,590 行,11 commits 全綠零 502。
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user