From fbc85fcedc7718b01b406e1541aa35e592946c8d Mon Sep 17 00:00:00 2001 From: OoO Date: Fri, 1 May 2026 14:12:56 +0800 Subject: [PATCH] =?UTF-8?q?refactor(vendor):=20=E6=8A=BD=E5=87=BA=E7=BC=BA?= =?UTF-8?q?=E8=B2=A8=20API=20=E6=9F=A5=E8=A9=A2=E6=9C=8D=E5=8B=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONSTITUTION.md | 2 +- app.py | 4 +- config.py | 2 +- .../code_modularization_inventory_20260430.md | 10 +- docs/memory/history_logs.md | 1 + routes/vendor_routes.py | 134 ++---------------- services/vendor_stockout_query_service.py | 118 +++++++++++++++ tests/test_frontend_v2_assets.py | 17 +++ 8 files changed, 157 insertions(+), 131 deletions(-) diff --git a/CONSTITUTION.md b/CONSTITUTION.md index 9b597ab..532f546 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -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 --- diff --git a/app.py b/app.py index 824fda7..47c44ca 100644 --- a/app.py +++ b/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 防護函數 diff --git a/config.py b/config.py index 3bacb97..f4a5593 100644 --- a/config.py +++ b/config.py @@ -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 # 用於模板顯示 diff --git a/docs/memory/code_modularization_inventory_20260430.md b/docs/memory/code_modularization_inventory_20260430.md index 7313ef7..99e5721 100644 --- a/docs/memory/code_modularization_inventory_20260430.md +++ b/docs/memory/code_modularization_inventory_20260430.md @@ -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 行檔案採「碰到就順手抽」策略,但不可讓淨行數繼續增加。 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index c627685..ecd43f8 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -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。 diff --git a/routes/vendor_routes.py b/routes/vendor_routes.py index 40113dd..2826898 100644 --- a/routes/vendor_routes.py +++ b/routes/vendor_routes.py @@ -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/', methods=['PUT']) diff --git a/services/vendor_stockout_query_service.py b/services/vendor_stockout_query_service.py index dbfda8f..c694583 100644 --- a/services/vendor_stockout_query_service.py +++ b/services/vendor_stockout_query_service.py @@ -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() diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index f854940..3c61f94 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -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/'", 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")