This commit is contained in:
@@ -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
|
||||
|
||||
---
|
||||
|
||||
4
app.py
4
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 防護函數
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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 行檔案採「碰到就順手抽」策略,但不可讓淨行數繼續增加。
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
182
services/vendor_stockout_query_service.py
Normal file
182
services/vendor_stockout_query_service.py
Normal file
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user