refactor(vendor): 抽出缺貨 API 查詢服務
All checks were successful
CD Pipeline / deploy (push) Successful in 2m11s
All checks were successful
CD Pipeline / deploy (push) Successful in 2m11s
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
> 本文件定義專案開發的核心準則與不可違反的規範
|
||||
> **建立日期**: 2026-01-12
|
||||
> **當前版本**: V10.51 (Price adjustment actions require human review)
|
||||
> **當前版本**: V10.52 (Vendor stockout API 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.51: Price adjustment actions require human review
|
||||
SYSTEM_VERSION = "V10.51"
|
||||
# 🚩 2026-05-01 V10.52: Vendor stockout API query service extraction
|
||||
SYSTEM_VERSION = "V10.52"
|
||||
|
||||
# ==========================================
|
||||
# 🔒 SQL Injection 防護函數
|
||||
|
||||
@@ -254,7 +254,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.50"
|
||||
SYSTEM_VERSION = "V10.52"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
## 盤點結論
|
||||
|
||||
- Python 總量:約 66,596 行。
|
||||
- 最大壓力區:`routes/` 約 21,038 行、`services/` 約 25,717 行。
|
||||
- Python 總量:約 66,719 行。
|
||||
- 最大壓力區:`routes/` 約 20,928 行、`services/` 約 25,929 行。
|
||||
- `app.py` 目前約 1,209 行,功能定位應固定為 bootstrap / Blueprint registration / startup guard,不再承接新 route。
|
||||
- 目前仍有 15 個 Python 檔案超過 800 行;這些不是禁止修 bug,而是禁止繼續塞新功能。
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
| 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 |
|
||||
| 1675 | `routes/vendor_routes.py` | P1 Vendor Blueprint | route glue / stockout service;V2 query 已抽到 `services/vendor_stockout_query_service.py` |
|
||||
| 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` |
|
||||
| 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 |
|
||||
@@ -25,7 +25,7 @@
|
||||
| 1079 | `routes/cicd_routes.py` | P2 CI/CD Blueprint | route glue / CI query service / deployment action 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 |
|
||||
| 868 | `services/elephant_alpha_autonomous_engine.py` | P2 ElephantAlpha engine | HITL / executor / planning policy |
|
||||
| 946 | `services/elephant_alpha_autonomous_engine.py` | P2 ElephantAlpha engine | HITL / executor / planning policy |
|
||||
| 818 | `services/import_service.py` | P2 import service | validators / import writers / report builders |
|
||||
| 805 | `routes/bot_api_routes.py` | P2 Bot API Blueprint | route glue / bot action service |
|
||||
|
||||
@@ -34,7 +34,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 query 第一刀已完成,下一步可抽 API list/batches 或 email grouping。
|
||||
4. P1:把 `routes/ai_routes.py` 與 `routes/vendor_routes.py` 的資料處理移出 route;Vendor V2 page query 與 API list/batches 已完成,下一步可抽 email grouping 或 vendor management query。
|
||||
5. P1:把 PPT / NemoTron / OpenClaw 大 service 拆成 client、parser、composer、policy。
|
||||
6. P2:對 800-1100 行檔案採「碰到就順手抽」策略,但不可讓淨行數繼續增加。
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
- **Vendor Stockout V2 production default**: `/vendor-stockout` 預設渲染 `vendor_stockout_index_v2.html`,`?ui=legacy` 才回舊頁;統計來自正式 `VendorStockout/VendorList/VendorEmail/EmailSendLog` 查詢,不建立假資料。
|
||||
- **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 行。
|
||||
|
||||
### 2026-04-28~29:Phase 3e 重構大戰 + daily_sales cache 隱形 bug 根除
|
||||
- **app.py 縮減 -10.8%**: 7,386 → 6,590 行,11 commits 全綠零 502。
|
||||
|
||||
@@ -19,6 +19,8 @@ from database.vendor_manager import VendorDatabaseManager
|
||||
from database.vendor_models import VendorStockout, VendorList, VendorEmail, EmailSendLog
|
||||
from services.logger_manager import SystemLogger
|
||||
from services.vendor_stockout_query_service import (
|
||||
get_stockout_api_list_payload,
|
||||
get_stockout_batches_payload,
|
||||
get_vendor_dashboard_stats,
|
||||
get_vendor_stockout_list_context,
|
||||
)
|
||||
@@ -331,104 +333,17 @@ def api_get_stockout_list():
|
||||
- sort_by: 排序方式 (created_at_desc/created_at_asc/vendor_code_asc/stockout_days_desc)
|
||||
"""
|
||||
try:
|
||||
# 取得查詢參數
|
||||
page = request.args.get('page', 1, type=int)
|
||||
page_size = request.args.get('page_size', 50, type=int)
|
||||
batch_number = request.args.get('batch_number')
|
||||
vendor_filter = request.args.get('vendor')
|
||||
status_filter = request.args.get('status')
|
||||
sort_by = request.args.get('sort_by', 'created_at_desc')
|
||||
|
||||
# 獲取 session
|
||||
session = vendor_db.get_session()
|
||||
|
||||
# 建立查詢
|
||||
query = session.query(VendorStockout)
|
||||
|
||||
# 套用篩選
|
||||
if batch_number:
|
||||
query = query.filter(VendorStockout.batch_id == batch_number)
|
||||
|
||||
if vendor_filter:
|
||||
query = query.filter(
|
||||
or_(
|
||||
VendorStockout.vendor_code.like(f'%{vendor_filter}%'),
|
||||
VendorStockout.vendor_name.like(f'%{vendor_filter}%')
|
||||
)
|
||||
)
|
||||
|
||||
if status_filter:
|
||||
query = query.filter(VendorStockout.status == status_filter)
|
||||
|
||||
# 套用排序
|
||||
if sort_by == 'created_at_asc':
|
||||
query = query.order_by(VendorStockout.created_at.asc())
|
||||
elif sort_by == 'vendor_code_asc':
|
||||
query = query.order_by(VendorStockout.vendor_code.asc())
|
||||
elif sort_by == 'stockout_days_desc':
|
||||
query = query.order_by(VendorStockout.safe_stock_days.desc())
|
||||
else: # created_at_desc
|
||||
query = query.order_by(VendorStockout.created_at.desc())
|
||||
|
||||
# 計算總數
|
||||
total = query.count()
|
||||
|
||||
# 分頁
|
||||
offset = (page - 1) * page_size
|
||||
records = query.offset(offset).limit(page_size).all()
|
||||
|
||||
# 轉換為 JSON 格式
|
||||
records_data = []
|
||||
for record in records:
|
||||
records_data.append({
|
||||
'id': record.id,
|
||||
'batch_number': record.batch_id,
|
||||
'vendor_code': record.vendor_code,
|
||||
'vendor_name': record.vendor_name,
|
||||
'product_code': record.product_code,
|
||||
'product_name': record.product_name,
|
||||
'stockout_days': record.safe_stock_days,
|
||||
'daily_avg_sales': float(record.daily_avg_sales) if record.daily_avg_sales else None,
|
||||
'current_stock': record.current_stock,
|
||||
'send_status': record.status or 'pending',
|
||||
'created_at': record.created_at.isoformat() if record.created_at else None,
|
||||
'notes': record.notes
|
||||
})
|
||||
|
||||
# 計算統計數據
|
||||
stats_query = session.query(VendorStockout)
|
||||
if batch_number:
|
||||
stats_query = stats_query.filter(VendorStockout.batch_id == batch_number)
|
||||
if vendor_filter:
|
||||
stats_query = stats_query.filter(
|
||||
or_(
|
||||
VendorStockout.vendor_code.like(f'%{vendor_filter}%'),
|
||||
VendorStockout.vendor_name.like(f'%{vendor_filter}%')
|
||||
)
|
||||
)
|
||||
|
||||
total_stats = stats_query.count()
|
||||
pending_count = stats_query.filter(or_(
|
||||
VendorStockout.status == 'pending',
|
||||
VendorStockout.status == None
|
||||
)).count()
|
||||
sent_count = stats_query.filter(VendorStockout.status == 'sent').count()
|
||||
vendor_count = session.query(VendorStockout.vendor_code).distinct().count()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'records': records_data,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'stats': {
|
||||
'total': total_stats,
|
||||
'pending': pending_count,
|
||||
'sent': sent_count,
|
||||
'vendor_count': vendor_count
|
||||
}
|
||||
}
|
||||
'data': get_stockout_api_list_payload(
|
||||
vendor_db,
|
||||
page=request.args.get('page', 1, type=int),
|
||||
page_size=request.args.get('page_size', 50, type=int),
|
||||
batch_number=request.args.get('batch_number'),
|
||||
vendor_filter=request.args.get('vendor'),
|
||||
status_filter=request.args.get('status'),
|
||||
sort_by=request.args.get('sort_by', 'created_at_desc')
|
||||
)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -436,45 +351,20 @@ def api_get_stockout_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/stockout/batches', methods=['GET'])
|
||||
def api_get_stockout_batches():
|
||||
"""取得批次清單"""
|
||||
try:
|
||||
session = vendor_db.get_session()
|
||||
|
||||
# 查詢所有批次及其數量
|
||||
batches = session.query(
|
||||
VendorStockout.batch_id,
|
||||
func.count(VendorStockout.id).label('count'),
|
||||
func.max(VendorStockout.created_at).label('latest_date')
|
||||
).group_by(VendorStockout.batch_id)\
|
||||
.order_by(desc('latest_date'))\
|
||||
.all()
|
||||
|
||||
batch_data = []
|
||||
for batch in batches:
|
||||
batch_data.append({
|
||||
'batch_number': batch.batch_id,
|
||||
'count': batch.count,
|
||||
'latest_date': batch.latest_date.isoformat() if batch.latest_date else None
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': batch_data
|
||||
'data': get_stockout_batches_payload(vendor_db)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
sys_log.error(f"[API] 取得批次清單失敗 | 錯誤: {e}")
|
||||
return jsonify({'success': False, 'message': f'查詢失敗: {str(e)}'}), 500
|
||||
finally:
|
||||
if 'session' in locals():
|
||||
session.close()
|
||||
|
||||
|
||||
@vendor_bp.route('/api/stockout/<int:stockout_id>', methods=['PUT'])
|
||||
|
||||
@@ -180,3 +180,121 @@ def get_vendor_stockout_list_context(
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _apply_stockout_api_filters(query, batch_number=None, vendor_filter=None):
|
||||
if batch_number:
|
||||
query = query.filter(VendorStockout.batch_id == batch_number)
|
||||
|
||||
if vendor_filter:
|
||||
query = query.filter(or_(
|
||||
VendorStockout.vendor_code.like(f'%{vendor_filter}%'),
|
||||
VendorStockout.vendor_name.like(f'%{vendor_filter}%')
|
||||
))
|
||||
|
||||
return query
|
||||
|
||||
|
||||
def _apply_stockout_api_sort(query, sort_by):
|
||||
if sort_by == 'created_at_asc':
|
||||
return query.order_by(VendorStockout.created_at.asc())
|
||||
if sort_by == 'vendor_code_asc':
|
||||
return query.order_by(VendorStockout.vendor_code.asc())
|
||||
if sort_by == 'stockout_days_desc':
|
||||
return query.order_by(VendorStockout.safe_stock_days.desc())
|
||||
return query.order_by(VendorStockout.created_at.desc())
|
||||
|
||||
|
||||
def _serialize_stockout_record(record):
|
||||
return {
|
||||
'id': record.id,
|
||||
'batch_number': record.batch_id,
|
||||
'vendor_code': record.vendor_code,
|
||||
'vendor_name': record.vendor_name,
|
||||
'product_code': record.product_code,
|
||||
'product_name': record.product_name,
|
||||
'stockout_days': record.safe_stock_days,
|
||||
'daily_avg_sales': float(record.daily_avg_sales) if record.daily_avg_sales else None,
|
||||
'current_stock': record.current_stock,
|
||||
'send_status': record.status or 'pending',
|
||||
'created_at': record.created_at.isoformat() if record.created_at else None,
|
||||
'notes': record.notes
|
||||
}
|
||||
|
||||
|
||||
def get_stockout_api_list_payload(
|
||||
vendor_db,
|
||||
page=1,
|
||||
page_size=50,
|
||||
batch_number=None,
|
||||
vendor_filter=None,
|
||||
status_filter=None,
|
||||
sort_by='created_at_desc',
|
||||
):
|
||||
"""回傳舊版缺貨清單 API 相容 payload。"""
|
||||
page = page or 1
|
||||
page_size = page_size or 50
|
||||
|
||||
session = vendor_db.get_session()
|
||||
try:
|
||||
query = _apply_stockout_api_filters(
|
||||
session.query(VendorStockout),
|
||||
batch_number=batch_number,
|
||||
vendor_filter=vendor_filter
|
||||
)
|
||||
|
||||
if status_filter:
|
||||
query = query.filter(VendorStockout.status == status_filter)
|
||||
|
||||
query = _apply_stockout_api_sort(query, sort_by)
|
||||
total = query.count()
|
||||
records = query.offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
stats_query = _apply_stockout_api_filters(
|
||||
session.query(VendorStockout),
|
||||
batch_number=batch_number,
|
||||
vendor_filter=vendor_filter
|
||||
)
|
||||
total_stats = stats_query.count()
|
||||
pending_count = stats_query.filter(_pending_stockout_filter()).count()
|
||||
sent_count = stats_query.filter(VendorStockout.status == 'sent').count()
|
||||
vendor_count = session.query(VendorStockout.vendor_code).distinct().count()
|
||||
|
||||
return {
|
||||
'records': [_serialize_stockout_record(record) for record in records],
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'stats': {
|
||||
'total': total_stats,
|
||||
'pending': pending_count,
|
||||
'sent': sent_count,
|
||||
'vendor_count': vendor_count
|
||||
}
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def get_stockout_batches_payload(vendor_db):
|
||||
"""回傳舊版批次清單 API 相容 payload。"""
|
||||
session = vendor_db.get_session()
|
||||
try:
|
||||
batches = session.query(
|
||||
VendorStockout.batch_id,
|
||||
func.count(VendorStockout.id).label('count'),
|
||||
func.max(VendorStockout.created_at).label('latest_date')
|
||||
).group_by(VendorStockout.batch_id)\
|
||||
.order_by(desc('latest_date'))\
|
||||
.all()
|
||||
|
||||
return [
|
||||
{
|
||||
'batch_number': batch.batch_id,
|
||||
'count': batch.count,
|
||||
'latest_date': batch.latest_date.isoformat() if batch.latest_date else None
|
||||
}
|
||||
for batch in batches
|
||||
]
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@@ -255,6 +255,23 @@ def test_vendor_stockout_list_v2_is_production_default_and_uses_real_stockout_ro
|
||||
assert "假" not in template
|
||||
|
||||
|
||||
def test_vendor_stockout_api_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_stockout_list():", 1)[1].split("@vendor_bp.route('/api/stockout/<int:stockout_id>'", 1)[0]
|
||||
|
||||
assert "get_stockout_api_list_payload(" in api_section
|
||||
assert "get_stockout_batches_payload(vendor_db)" in api_section
|
||||
assert "session.query(VendorStockout)" not in api_section
|
||||
assert "def get_stockout_api_list_payload(" in service_source
|
||||
assert "def get_stockout_batches_payload(vendor_db)" in service_source
|
||||
assert "'batch_number': record.batch_id" in service_source
|
||||
assert "'send_status': record.status or 'pending'" in service_source
|
||||
assert "'latest_date': batch.latest_date.isoformat()" 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