diff --git a/CONSTITUTION.md b/CONSTITUTION.md index 7739bf6..6dc3068 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -2,7 +2,7 @@ > 本文件定義專案開發的核心準則與不可違反的規範 > **建立日期**: 2026-01-12 -> **當前版本**: V10.53 (Dashboard competitor decision overview) +> **當前版本**: V10.54 (Vendor management query service extraction) > **最後更新**: 2026-05-01 --- diff --git a/app.py b/app.py index 0ff5f8b..a4ce81d 100644 --- a/app.py +++ b/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 防護函數 diff --git a/config.py b/config.py index af8b8e3..6624dfa 100644 --- a/config.py +++ b/config.py @@ -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 # 用於模板顯示 diff --git a/docs/memory/code_modularization_inventory_20260430.md b/docs/memory/code_modularization_inventory_20260430.md index 99e5721..3a52bd3 100644 --- a/docs/memory/code_modularization_inventory_20260430.md +++ b/docs/memory/code_modularization_inventory_20260430.md @@ -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 行檔案採「碰到就順手抽」策略,但不可讓淨行數繼續增加。 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index ecd43f8..d02391c 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -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/` 的 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。 diff --git a/routes/vendor_routes.py b/routes/vendor_routes.py index 2826898..e51e0a9 100644 --- a/routes/vendor_routes.py +++ b/routes/vendor_routes.py @@ -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/', methods=['PUT']) diff --git a/services/vendor_stockout_query_service.py b/services/vendor_stockout_query_service.py index c694583..a8a2cf7 100644 --- a/services/vendor_stockout_query_service.py +++ b/services/vendor_stockout_query_service.py @@ -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() diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index 182f97f..9392d84 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -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/', 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")