diff --git a/CONSTITUTION.md b/CONSTITUTION.md index 3830a96..ba5bbae 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -2,7 +2,7 @@ > 本文件定義專案開發的核心準則與不可違反的規範 > **建立日期**: 2026-01-12 -> **當前版本**: V10.49 (Scheduled PChome backfill with pick regeneration) +> **當前版本**: V10.50 (Vendor stockout query service extraction) > **最後更新**: 2026-05-01 --- diff --git a/app.py b/app.py index 9ad92e0..32b8b69 100644 --- a/app.py +++ b/app.py @@ -95,8 +95,8 @@ except Exception as e: sys_log.error(f"無法檢測磁碟空間: {e}") # 🚩 系統版本定義 (備份與顯示用) -# 🚩 2026-05-01 V10.49: Scheduled PChome backfill with pick regeneration -SYSTEM_VERSION = "V10.49" +# 🚩 2026-05-01 V10.50: Vendor stockout query service extraction +SYSTEM_VERSION = "V10.50" # ========================================== # 🔒 SQL Injection 防護函數 diff --git a/config.py b/config.py index b1b8453..3bacb97 100644 --- a/config.py +++ b/config.py @@ -254,7 +254,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.38" +SYSTEM_VERSION = "V10.50" 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 7cda6e2..7313ef7 100644 --- a/docs/memory/code_modularization_inventory_20260430.md +++ b/docs/memory/code_modularization_inventory_20260430.md @@ -4,9 +4,9 @@ ## 盤點結論 -- Python 總量:約 65,113 行。 -- 最大壓力區:`routes/` 約 20,717 行、`services/` 約 24,908 行。 -- `app.py` 已降到 1,206 行,功能定位應固定為 bootstrap / Blueprint registration / startup guard,不再承接新 route。 +- Python 總量:約 66,596 行。 +- 最大壓力區:`routes/` 約 21,038 行、`services/` 約 25,717 行。 +- `app.py` 目前約 1,209 行,功能定位應固定為 bootstrap / Blueprint registration / startup guard,不再承接新 route。 - 目前仍有 15 個 Python 檔案超過 800 行;這些不是禁止修 bug,而是禁止繼續塞新功能。 ## 超過 800 行檔案清單 @@ -14,14 +14,14 @@ | 行數 | 檔案 | 分類 | 拆分方向 | |---:|---|---|---| | 5240 | `routes/openclaw_bot_routes.py` | P0 巨型 Blueprint | route / bot command service / report service / scheduler hook | +| 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 | -| 2644 | `scheduler.py` | P0 排程總管 | task registry / crawler jobs / report jobs / notification jobs | -| 1662 | `routes/ai_routes.py` | P1 AI Blueprint | route glue / AI orchestration service / prompt builders | -| 1661 | `routes/vendor_routes.py` | P1 Vendor Blueprint | route glue / vendor query service / stockout 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` | | 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 | -| 1206 | `app.py` | P1 bootstrap | 保持只做 app setup;繼續往 app_factory / extension setup 抽 | +| 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 | | 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 | @@ -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。 +4. P1:把 `routes/ai_routes.py` 與 `routes/vendor_routes.py` 的資料處理移出 route;Vendor V2 query 第一刀已完成,下一步可抽 API list/batches 或 email grouping。 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 bae8133..c627685 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -61,8 +61,9 @@ - **EDM Dashboard V2 feature flag**: 五個活動看板入口預設仍走既有 `edm_dashboard.html`,加上 `?ui=v2` 才渲染 `edm_dashboard_v2.html`;新版頁沿用既有活動真實 `grouped_items/slot_stats/scheduler_stats`。 ### 2026-05-01:Frontend V2 營運頁推進 -- **Vendor Stockout V2 feature flag**: `/vendor-stockout` 預設仍走既有頁;`/vendor-stockout?ui=v2` 才渲染 `vendor_stockout_index_v2.html`,統計來自正式 `VendorStockout/VendorList/VendorEmail/EmailSendLog` 查詢,不建立假資料。 -- **Vendor Stockout List V2 feature flag**: `/vendor-stockout/list` 預設仍走既有頁;`/vendor-stockout/list?ui=v2` 才渲染 `vendor_stockout_list_v2.html`,清單、批次、搜尋、分頁與狀態統計都從正式 `VendorStockout` 查詢組裝,並移除重複的 `web/templates/vendor_stockout_index_v2.html`。 +- **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 繼續承接資料組裝邏輯。 ### 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 16a0d49..40113dd 100644 --- a/routes/vendor_routes.py +++ b/routes/vendor_routes.py @@ -18,6 +18,10 @@ import json 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_vendor_dashboard_stats, + get_vendor_stockout_list_context, +) # 初始化日誌 sys_log = SystemLogger("VendorRoutes").get_logger() @@ -36,164 +40,6 @@ vendor_db = VendorDatabaseManager() # ========================================== -def _get_vendor_dashboard_stats(): - """彙整廠商缺貨首頁 V2 所需的真實資料庫統計。""" - session = vendor_db.get_session() - try: - total_stockouts = session.query(VendorStockout).count() - pending_stockouts = session.query(VendorStockout).filter(or_( - VendorStockout.status == 'pending', - VendorStockout.status.is_(None) - )).count() - sent_stockouts = session.query(VendorStockout).filter(VendorStockout.status == 'sent').count() - failed_stockouts = session.query(VendorStockout).filter(VendorStockout.status == 'failed').count() - duplicate_stockouts = session.query(VendorStockout).filter(VendorStockout.is_duplicate.is_(True)).count() - distinct_vendor_count = session.query(VendorStockout.vendor_code).distinct().count() - - active_vendors = session.query(VendorList).filter(VendorList.is_active.is_(True)).count() - inactive_vendors = session.query(VendorList).filter(VendorList.is_active.is_(False)).count() - active_emails = session.query(VendorEmail).filter(VendorEmail.is_active.is_(True)).count() - - email_total = session.query(EmailSendLog).count() - email_success = session.query(EmailSendLog).filter(EmailSendLog.status == 'sent').count() - email_failed = session.query(EmailSendLog).filter(EmailSendLog.status == 'failed').count() - email_pending = session.query(EmailSendLog).filter(EmailSendLog.status == 'pending').count() - email_success_rate = round(email_success / email_total * 100, 1) if email_total else 0 - - latest_import = session.query(VendorStockout).order_by(desc(VendorStockout.created_at)).first() - latest_email = session.query(EmailSendLog).order_by(desc(EmailSendLog.created_at)).first() - latest_batch_time = func.max(VendorStockout.created_at).label('latest_date') - latest_batch = session.query( - VendorStockout.batch_id, - func.count(VendorStockout.id).label('record_count'), - latest_batch_time - ).group_by(VendorStockout.batch_id).order_by(desc(latest_batch_time)).first() - - return { - 'total_stockouts': total_stockouts, - 'pending_stockouts': pending_stockouts, - 'sent_stockouts': sent_stockouts, - 'failed_stockouts': failed_stockouts, - 'duplicate_stockouts': duplicate_stockouts, - 'distinct_vendor_count': distinct_vendor_count, - 'active_vendors': active_vendors, - 'inactive_vendors': inactive_vendors, - 'active_emails': active_emails, - 'email_total': email_total, - 'email_success': email_success, - 'email_failed': email_failed, - 'email_pending': email_pending, - 'email_success_rate': email_success_rate, - 'latest_import_time': latest_import.created_at if latest_import else None, - 'latest_import_vendor': latest_import.vendor_name if latest_import else None, - 'latest_import_product': latest_import.product_name if latest_import else None, - 'latest_email_time': latest_email.created_at if latest_email else None, - 'latest_email_status': latest_email.status if latest_email else None, - 'latest_email_vendor_id': latest_email.vendor_id if latest_email else None, - 'latest_batch_id': latest_batch.batch_id if latest_batch else None, - 'latest_batch_count': latest_batch.record_count if latest_batch else 0, - 'latest_batch_time': latest_batch.latest_date if latest_batch else None, - } - finally: - session.close() - - -def _apply_stockout_filters(query, batch_id=None, vendor_keyword=None): - if batch_id: - query = query.filter(VendorStockout.batch_id == batch_id) - - if vendor_keyword: - query = query.filter(or_( - VendorStockout.vendor_code.like(f'%{vendor_keyword}%'), - VendorStockout.vendor_name.like(f'%{vendor_keyword}%'), - VendorStockout.product_code.like(f'%{vendor_keyword}%'), - VendorStockout.product_name.like(f'%{vendor_keyword}%') - )) - - return query - - -def _get_vendor_stockout_list_context(): - """缺貨清單 V2:以資料庫真實資料組裝篩選、分頁與列表。""" - session = vendor_db.get_session() - try: - page = max(request.args.get('page', 1, type=int), 1) - page_size = min(max(request.args.get('page_size', 30, type=int), 10), 100) - status_filter = (request.args.get('status') or 'all').strip() - batch_filter = (request.args.get('batch') or '').strip() - vendor_keyword = (request.args.get('q') or '').strip() - sort_by = (request.args.get('sort') or 'created_desc').strip() - - filtered_base = _apply_stockout_filters( - session.query(VendorStockout), - batch_filter, - vendor_keyword - ) - - query = filtered_base - if status_filter == 'pending': - query = query.filter(or_( - VendorStockout.status == 'pending', - VendorStockout.status.is_(None) - )) - elif status_filter in ['sent', 'failed']: - query = query.filter(VendorStockout.status == status_filter) - elif status_filter == 'duplicate': - query = query.filter(VendorStockout.is_duplicate.is_(True)) - else: - status_filter = 'all' - - if sort_by == 'created_asc': - query = query.order_by(VendorStockout.created_at.asc()) - elif sort_by == 'vendor_asc': - query = query.order_by(VendorStockout.vendor_code.asc(), VendorStockout.created_at.desc()) - elif sort_by == 'stockout_days_desc': - query = query.order_by(VendorStockout.stockout_days.desc().nullslast(), VendorStockout.created_at.desc()) - else: - sort_by = 'created_desc' - query = query.order_by(VendorStockout.created_at.desc()) - - total_items = query.count() - total_pages = max((total_items + page_size - 1) // page_size, 1) - if page > total_pages: - page = total_pages - - records = query.offset((page - 1) * page_size).limit(page_size).all() - - latest_batch_time = func.max(VendorStockout.created_at).label('latest_date') - batches = session.query( - VendorStockout.batch_id, - func.count(VendorStockout.id).label('record_count'), - latest_batch_time - ).group_by(VendorStockout.batch_id).order_by(desc(latest_batch_time)).limit(30).all() - - return { - 'records': records, - 'batches': batches, - 'total_items': total_items, - 'total_pages': total_pages, - 'current_page': page, - 'page_size': page_size, - 'current_status': status_filter, - 'current_batch': batch_filter, - 'search_query': vendor_keyword, - 'current_sort': sort_by, - 'stats': { - 'total': filtered_base.count(), - 'pending': filtered_base.filter(or_( - VendorStockout.status == 'pending', - VendorStockout.status.is_(None) - )).count(), - 'sent': filtered_base.filter(VendorStockout.status == 'sent').count(), - 'failed': filtered_base.filter(VendorStockout.status == 'failed').count(), - 'duplicate': filtered_base.filter(VendorStockout.is_duplicate.is_(True)).count(), - 'vendor_count': filtered_base.with_entities(VendorStockout.vendor_code).distinct().count(), - } - } - finally: - session.close() - - @vendor_bp.route('/') def index(): """廠商缺貨系統主頁""" @@ -203,7 +49,7 @@ def index(): return render_template( 'vendor_stockout_index_v2.html', active_page='vendor_stockout', - stats=_get_vendor_dashboard_stats() + stats=get_vendor_dashboard_stats(vendor_db) ) @@ -228,7 +74,15 @@ def list_page(): return render_template( 'vendor_stockout_list_v2.html', active_page='vendor_stockout', - **_get_vendor_stockout_list_context() + **get_vendor_stockout_list_context( + vendor_db, + page=request.args.get('page', 1, type=int), + page_size=request.args.get('page_size', 30, type=int), + status_filter=request.args.get('status'), + batch_filter=request.args.get('batch'), + vendor_keyword=request.args.get('q'), + sort_by=request.args.get('sort'), + ) ) diff --git a/services/vendor_stockout_query_service.py b/services/vendor_stockout_query_service.py new file mode 100644 index 0000000..dbfda8f --- /dev/null +++ b/services/vendor_stockout_query_service.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +廠商缺貨查詢服務 + +集中管理 Vendor V2 頁面所需的統計、篩選、分頁與排序查詢,讓 +routes/vendor_routes.py 保持 thin Blueprint。 +""" + +from sqlalchemy import desc, func, or_ + +from database.vendor_models import EmailSendLog, VendorEmail, VendorList, VendorStockout + + +def _pending_stockout_filter(): + return or_( + VendorStockout.status == 'pending', + VendorStockout.status.is_(None) + ) + + +def get_vendor_dashboard_stats(vendor_db): + """彙整廠商缺貨首頁 V2 所需的真實資料庫統計。""" + session = vendor_db.get_session() + try: + total_stockouts = session.query(VendorStockout).count() + pending_stockouts = session.query(VendorStockout).filter(_pending_stockout_filter()).count() + sent_stockouts = session.query(VendorStockout).filter(VendorStockout.status == 'sent').count() + failed_stockouts = session.query(VendorStockout).filter(VendorStockout.status == 'failed').count() + duplicate_stockouts = session.query(VendorStockout).filter(VendorStockout.is_duplicate.is_(True)).count() + distinct_vendor_count = session.query(VendorStockout.vendor_code).distinct().count() + + active_vendors = session.query(VendorList).filter(VendorList.is_active.is_(True)).count() + inactive_vendors = session.query(VendorList).filter(VendorList.is_active.is_(False)).count() + active_emails = session.query(VendorEmail).filter(VendorEmail.is_active.is_(True)).count() + + email_total = session.query(EmailSendLog).count() + email_success = session.query(EmailSendLog).filter(EmailSendLog.status == 'sent').count() + email_failed = session.query(EmailSendLog).filter(EmailSendLog.status == 'failed').count() + email_pending = session.query(EmailSendLog).filter(EmailSendLog.status == 'pending').count() + email_success_rate = round(email_success / email_total * 100, 1) if email_total else 0 + + latest_import = session.query(VendorStockout).order_by(desc(VendorStockout.created_at)).first() + latest_email = session.query(EmailSendLog).order_by(desc(EmailSendLog.created_at)).first() + latest_batch_time = func.max(VendorStockout.created_at).label('latest_date') + latest_batch = session.query( + VendorStockout.batch_id, + func.count(VendorStockout.id).label('record_count'), + latest_batch_time + ).group_by(VendorStockout.batch_id).order_by(desc(latest_batch_time)).first() + + return { + 'total_stockouts': total_stockouts, + 'pending_stockouts': pending_stockouts, + 'sent_stockouts': sent_stockouts, + 'failed_stockouts': failed_stockouts, + 'duplicate_stockouts': duplicate_stockouts, + 'distinct_vendor_count': distinct_vendor_count, + 'active_vendors': active_vendors, + 'inactive_vendors': inactive_vendors, + 'active_emails': active_emails, + 'email_total': email_total, + 'email_success': email_success, + 'email_failed': email_failed, + 'email_pending': email_pending, + 'email_success_rate': email_success_rate, + 'latest_import_time': latest_import.created_at if latest_import else None, + 'latest_import_vendor': latest_import.vendor_name if latest_import else None, + 'latest_import_product': latest_import.product_name if latest_import else None, + 'latest_email_time': latest_email.created_at if latest_email else None, + 'latest_email_status': latest_email.status if latest_email else None, + 'latest_email_vendor_id': latest_email.vendor_id if latest_email else None, + 'latest_batch_id': latest_batch.batch_id if latest_batch else None, + 'latest_batch_count': latest_batch.record_count if latest_batch else 0, + 'latest_batch_time': latest_batch.latest_date if latest_batch else None, + } + finally: + session.close() + + +def _apply_stockout_filters(query, batch_id=None, vendor_keyword=None): + if batch_id: + query = query.filter(VendorStockout.batch_id == batch_id) + + if vendor_keyword: + query = query.filter(or_( + VendorStockout.vendor_code.like(f'%{vendor_keyword}%'), + VendorStockout.vendor_name.like(f'%{vendor_keyword}%'), + VendorStockout.product_code.like(f'%{vendor_keyword}%'), + VendorStockout.product_name.like(f'%{vendor_keyword}%') + )) + + return query + + +def _apply_stockout_status_filter(query, status_filter): + if status_filter == 'pending': + return query.filter(_pending_stockout_filter()), status_filter + if status_filter in ['sent', 'failed']: + return query.filter(VendorStockout.status == status_filter), status_filter + if status_filter == 'duplicate': + return query.filter(VendorStockout.is_duplicate.is_(True)), status_filter + return query, 'all' + + +def _apply_stockout_sort(query, sort_by): + if sort_by == 'created_asc': + return query.order_by(VendorStockout.created_at.asc()), sort_by + if sort_by == 'vendor_asc': + return query.order_by(VendorStockout.vendor_code.asc(), VendorStockout.created_at.desc()), sort_by + if sort_by == 'stockout_days_desc': + return query.order_by( + VendorStockout.stockout_days.desc().nullslast(), + VendorStockout.created_at.desc() + ), sort_by + return query.order_by(VendorStockout.created_at.desc()), 'created_desc' + + +def get_vendor_stockout_list_context( + vendor_db, + page=1, + page_size=30, + status_filter='all', + batch_filter='', + vendor_keyword='', + sort_by='created_desc', +): + """缺貨清單 V2:以資料庫真實資料組裝篩選、分頁與列表。""" + page = max(page or 1, 1) + page_size = min(max(page_size or 30, 10), 100) + status_filter = (status_filter or 'all').strip() + batch_filter = (batch_filter or '').strip() + vendor_keyword = (vendor_keyword or '').strip() + sort_by = (sort_by or 'created_desc').strip() + + session = vendor_db.get_session() + try: + filtered_base = _apply_stockout_filters( + session.query(VendorStockout), + batch_filter, + vendor_keyword + ) + + query, status_filter = _apply_stockout_status_filter(filtered_base, status_filter) + query, sort_by = _apply_stockout_sort(query, sort_by) + + total_items = query.count() + total_pages = max((total_items + page_size - 1) // page_size, 1) + if page > total_pages: + page = total_pages + + records = query.offset((page - 1) * page_size).limit(page_size).all() + + latest_batch_time = func.max(VendorStockout.created_at).label('latest_date') + batches = session.query( + VendorStockout.batch_id, + func.count(VendorStockout.id).label('record_count'), + latest_batch_time + ).group_by(VendorStockout.batch_id).order_by(desc(latest_batch_time)).limit(30).all() + + return { + 'records': records, + 'batches': batches, + 'total_items': total_items, + 'total_pages': total_pages, + 'current_page': page, + 'page_size': page_size, + 'current_status': status_filter, + 'current_batch': batch_filter, + 'search_query': vendor_keyword, + 'current_sort': sort_by, + 'stats': { + 'total': filtered_base.count(), + 'pending': filtered_base.filter(_pending_stockout_filter()).count(), + 'sent': filtered_base.filter(VendorStockout.status == 'sent').count(), + 'failed': filtered_base.filter(VendorStockout.status == 'failed').count(), + 'duplicate': filtered_base.filter(VendorStockout.is_duplicate.is_(True)).count(), + 'vendor_count': filtered_base.with_entities(VendorStockout.vendor_code).distinct().count(), + } + } + finally: + session.close() diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index bae3b68..f854940 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -213,14 +213,16 @@ def test_edm_dashboard_v2_is_production_default_and_uses_real_campaign_data(): def test_vendor_stockout_v2_is_production_default_and_uses_real_vendor_data(): 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") template = (ROOT / "templates/vendor_stockout_index_v2.html").read_text(encoding="utf-8") assert "request.args.get('ui') == 'legacy'" in route_source assert not (ROOT / "web/templates/vendor_stockout_index_v2.html").exists() assert "vendor_stockout_index_v2.html" in route_source - assert "_get_vendor_dashboard_stats()" in route_source - assert "session.query(VendorStockout).count()" in route_source - assert "EmailSendLog" in route_source + assert "get_vendor_dashboard_stats(vendor_db)" in route_source + assert "def get_vendor_dashboard_stats(vendor_db)" in service_source + assert "session.query(VendorStockout).count()" in service_source + assert "EmailSendLog" in service_source assert "stats.pending_stockouts" in template assert "stats.email_success_rate" in template assert "ui='v2'" not in template @@ -228,16 +230,20 @@ def test_vendor_stockout_v2_is_production_default_and_uses_real_vendor_data(): assert "假" not in template -def test_vendor_stockout_list_v2_is_feature_flagged_and_uses_real_stockout_rows(): +def test_vendor_stockout_list_v2_is_production_default_and_uses_real_stockout_rows(): 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") template = (ROOT / "templates/vendor_stockout_list_v2.html").read_text(encoding="utf-8") assert "vendor_stockout_list_v2.html" in route_source - assert "_get_vendor_stockout_list_context()" in route_source - assert "_apply_stockout_filters(" in route_source - assert "session.query(VendorStockout)" in route_source - assert "VendorStockout.status == 'pending'" in route_source - assert "VendorStockout.batch_id == batch_id" in route_source + assert "get_vendor_stockout_list_context(" in route_source + assert "request.args.get('page', 1, type=int)" in route_source + assert "def get_vendor_stockout_list_context(" in service_source + assert "_apply_stockout_filters(" in service_source + assert "session.query(VendorStockout)" in service_source + assert "VendorStockout.status == 'pending'" in service_source + assert "VendorStockout.batch_id == batch_id" in service_source + assert "from flask" not in service_source assert "{% for record in records %}" in template assert "{% for batch in batches %}" in template assert "stats.pending" in template