fix(analytics): align yoy and detail table contracts
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
This commit is contained in:
@@ -414,7 +414,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.805"
|
||||
SYSTEM_VERSION = "V10.806"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ import os
|
||||
import pickle
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from calendar import monthrange
|
||||
from datetime import date, datetime, timezone, timedelta
|
||||
from flask import Blueprint, request, render_template, jsonify, send_file, redirect, url_for
|
||||
from auth import login_required
|
||||
from sqlalchemy import inspect, text
|
||||
@@ -30,6 +31,7 @@ from services.analysis_period_service import (
|
||||
make_analysis_period,
|
||||
make_month_period,
|
||||
month_end,
|
||||
parse_iso_date,
|
||||
parse_month,
|
||||
)
|
||||
from services.cache_manager import (
|
||||
@@ -59,6 +61,89 @@ _SALES_PAGE_CONTEXT_CACHE_MAX = 24
|
||||
_SALES_SHARED_PAGE_CONTEXT_CACHE_TTL = 1800
|
||||
|
||||
|
||||
def _parse_yoy_year(value, default):
|
||||
raw = str(value or default).strip()
|
||||
if not raw.isdigit() or len(raw) != 4:
|
||||
raise ValueError('year1/year2 必須是四位數年份')
|
||||
year = int(raw)
|
||||
if year < 2000 or year > 2100:
|
||||
raise ValueError('year1/year2 超出允許範圍')
|
||||
return year
|
||||
|
||||
|
||||
def _safe_period_date(year, month, day):
|
||||
return date(year, month, min(day, monthrange(year, month)[1]))
|
||||
|
||||
|
||||
def _project_yoy_period(target_year, start_value=None, end_value=None, month_value=None):
|
||||
start = parse_iso_date(start_value)
|
||||
end = parse_iso_date(end_value)
|
||||
month_raw = str(month_value or '').strip().lower()
|
||||
|
||||
if start_value and not start:
|
||||
raise ValueError('start_date 必須是 YYYY-MM-DD')
|
||||
if end_value and not end:
|
||||
raise ValueError('end_date 必須是 YYYY-MM-DD')
|
||||
|
||||
if start or end:
|
||||
start = start or end
|
||||
end = end or start
|
||||
if end < start:
|
||||
start, end = end, start
|
||||
wraps_year = end.year > start.year or (end.month, end.day) < (start.month, start.day)
|
||||
projected_start = _safe_period_date(target_year, start.month, start.day)
|
||||
projected_end = _safe_period_date(target_year + int(wraps_year), end.month, end.day)
|
||||
return projected_start, projected_end
|
||||
|
||||
if month_raw and month_raw != 'all':
|
||||
if not month_raw.isdigit() or not 1 <= int(month_raw) <= 12:
|
||||
raise ValueError('month 必須介於 1 到 12')
|
||||
month = int(month_raw)
|
||||
return date(target_year, month, 1), date(target_year, month, monthrange(target_year, month)[1])
|
||||
|
||||
return date(target_year, 1, 1), date(target_year, 12, 31)
|
||||
|
||||
|
||||
def _period_month_keys(start, end):
|
||||
current = date(start.year, start.month, 1)
|
||||
terminal = date(end.year, end.month, 1)
|
||||
keys = []
|
||||
while current <= terminal:
|
||||
keys.append(current.strftime('%Y-%m'))
|
||||
if current.month == 12:
|
||||
current = date(current.year + 1, 1, 1)
|
||||
else:
|
||||
current = date(current.year, current.month + 1, 1)
|
||||
return keys
|
||||
|
||||
|
||||
def _query_yoy_month_totals(engine, aggregate_sql, start, end):
|
||||
if engine.dialect.name == 'postgresql':
|
||||
date_expr = 'LEFT(REPLACE(CAST("日期" AS TEXT), \'/\', \'-\'), 10)'
|
||||
month_expr = f'LEFT({date_expr}, 7)'
|
||||
else:
|
||||
date_expr = 'substr(replace(CAST("日期" AS TEXT), \'/\', \'-\'), 1, 10)'
|
||||
month_expr = f'substr({date_expr}, 1, 7)'
|
||||
|
||||
query = text(f"""
|
||||
SELECT {month_expr} AS period_month, {aggregate_sql} AS total
|
||||
FROM realtime_sales_monthly
|
||||
WHERE {date_expr} >= :start_date AND {date_expr} <= :end_date
|
||||
GROUP BY {month_expr}
|
||||
ORDER BY {month_expr}
|
||||
""")
|
||||
frame = pd.read_sql(
|
||||
query,
|
||||
engine,
|
||||
params={'start_date': start.isoformat(), 'end_date': end.isoformat()},
|
||||
)
|
||||
return {
|
||||
str(row.period_month): float(row.total or 0)
|
||||
for row in frame.itertuples(index=False)
|
||||
if row.period_month
|
||||
}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 輔助函數
|
||||
# ==========================================
|
||||
@@ -3285,132 +3370,88 @@ def api_export_top_detail():
|
||||
@sales_bp.route('/api/sales_analysis/yoy_comparison')
|
||||
@login_required
|
||||
def api_yoy_comparison():
|
||||
"""
|
||||
API: 年度對比分析 (YoY Comparison)
|
||||
|
||||
參數:
|
||||
year1: 基準年 (例如 2024)
|
||||
year2: 對比年 (例如 2025)
|
||||
month: 月份 (可選,1-12,不帶則為全年)
|
||||
metric: 指標 (revenue/qty/profit)
|
||||
|
||||
回傳:
|
||||
JSON with year1 total, year2 total, growth rate, and monthly breakdown
|
||||
"""
|
||||
"""Return a period-linked year-over-year comparison."""
|
||||
try:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
table_name = 'realtime_sales_monthly'
|
||||
year1 = request.args.get('year1', '2024')
|
||||
year2 = request.args.get('year2', '2025')
|
||||
month = request.args.get('month', '') # 可選,1-12
|
||||
metric = request.args.get('metric', 'revenue') # revenue/qty/profit
|
||||
|
||||
db = DatabaseManager()
|
||||
|
||||
# 欄位名稱
|
||||
col_amount = '總業績'
|
||||
col_qty = '數量'
|
||||
col_cost = '總成本'
|
||||
col_date = '日期'
|
||||
|
||||
# 根據指標決定聚合欄位
|
||||
if metric == 'qty':
|
||||
agg_sql = f'SUM(CAST("{col_qty}" AS REAL))'
|
||||
metric_label = '銷售數量'
|
||||
elif metric == 'profit':
|
||||
agg_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))'
|
||||
metric_label = '毛利金額'
|
||||
else: # revenue
|
||||
agg_sql = f'SUM(CAST("{col_amount}" AS REAL))'
|
||||
metric_label = '銷售金額'
|
||||
|
||||
# 建立年度篩選條件
|
||||
# 日期格式為 2025/01/01 或 2025-01-01
|
||||
def build_year_filter(year, month_filter=''):
|
||||
if month_filter:
|
||||
month_str = month_filter.zfill(2)
|
||||
return f"""("{col_date}" LIKE '{year}/{month_str}%' OR "{col_date}" LIKE '{year}-{month_str}%')"""
|
||||
else:
|
||||
return f"""("{col_date}" LIKE '{year}/%' OR "{col_date}" LIKE '{year}-%')"""
|
||||
|
||||
year1_filter = build_year_filter(year1, month)
|
||||
year2_filter = build_year_filter(year2, month)
|
||||
|
||||
# 查詢年度總計
|
||||
sql_year1 = f"""
|
||||
SELECT {agg_sql} as total
|
||||
FROM {table_name}
|
||||
WHERE {year1_filter}
|
||||
"""
|
||||
sql_year2 = f"""
|
||||
SELECT {agg_sql} as total
|
||||
FROM {table_name}
|
||||
WHERE {year2_filter}
|
||||
"""
|
||||
year1 = _parse_yoy_year(request.args.get('year1'), 2024)
|
||||
year2 = _parse_yoy_year(request.args.get('year2'), 2025)
|
||||
metric = str(request.args.get('metric', 'revenue')).strip().lower()
|
||||
metric_options = {
|
||||
'revenue': ('COALESCE(SUM(CAST("總業績" AS REAL)), 0)', '銷售金額'),
|
||||
'qty': ('COALESCE(SUM(CAST("數量" AS REAL)), 0)', '銷售數量'),
|
||||
'profit': (
|
||||
'COALESCE(SUM(CAST("總業績" AS REAL)), 0) '
|
||||
'- COALESCE(SUM(CAST("總成本" AS REAL)), 0)',
|
||||
'毛利金額',
|
||||
),
|
||||
}
|
||||
if metric not in metric_options:
|
||||
raise ValueError('metric 僅允許 revenue、qty、profit')
|
||||
|
||||
start_value = request.args.get('start_date')
|
||||
end_value = request.args.get('end_date')
|
||||
month_value = request.args.get('month')
|
||||
year1_start, year1_end = _project_yoy_period(year1, start_value, end_value, month_value)
|
||||
year2_start, year2_end = _project_yoy_period(year2, start_value, end_value, month_value)
|
||||
|
||||
aggregate_sql, metric_label = metric_options[metric]
|
||||
engine = DatabaseManager().engine
|
||||
year1_values = _query_yoy_month_totals(engine, aggregate_sql, year1_start, year1_end)
|
||||
year2_values = _query_yoy_month_totals(engine, aggregate_sql, year2_start, year2_end)
|
||||
year1_keys = _period_month_keys(year1_start, year1_end)
|
||||
year2_keys = _period_month_keys(year2_start, year2_end)
|
||||
|
||||
# V-Fix: SQLAlchemy 2.0 需要使用 text() 包裹 SQL 字串
|
||||
from sqlalchemy import text
|
||||
result_year1 = pd.read_sql(text(sql_year1), db.engine)
|
||||
result_year2 = pd.read_sql(text(sql_year2), db.engine)
|
||||
|
||||
total_year1 = float(result_year1['total'].iloc[0] or 0)
|
||||
total_year2 = float(result_year2['total'].iloc[0] or 0)
|
||||
|
||||
# 計算成長率
|
||||
if total_year1 > 0:
|
||||
growth_rate = ((total_year2 - total_year1) / total_year1) * 100
|
||||
else:
|
||||
growth_rate = 0 if total_year2 == 0 else 100
|
||||
|
||||
# 月度明細 (如果沒有指定月份,則查詢 12 個月的明細)
|
||||
monthly_breakdown = []
|
||||
if not month:
|
||||
for m in range(1, 13):
|
||||
m_str = str(m).zfill(2)
|
||||
y1_filter = build_year_filter(year1, m_str)
|
||||
y2_filter = build_year_filter(year2, m_str)
|
||||
|
||||
sql_m1 = f"SELECT {agg_sql} as total FROM {table_name} WHERE {y1_filter}"
|
||||
sql_m2 = f"SELECT {agg_sql} as total FROM {table_name} WHERE {y2_filter}"
|
||||
for index in range(max(len(year1_keys), len(year2_keys))):
|
||||
year1_key = year1_keys[index] if index < len(year1_keys) else None
|
||||
year2_key = year2_keys[index] if index < len(year2_keys) else None
|
||||
value1 = year1_values.get(year1_key, 0) if year1_key else 0
|
||||
value2 = year2_values.get(year2_key, 0) if year2_key else 0
|
||||
growth = ((value2 - value1) / value1 * 100) if value1 else (100 if value2 else 0)
|
||||
label_key = year2_key or year1_key
|
||||
monthly_breakdown.append({
|
||||
'month': int(label_key[-2:]),
|
||||
'month_label': f'{int(label_key[-2:])}月',
|
||||
'year1_value': value1,
|
||||
'year2_value': value2,
|
||||
'growth_rate': round(growth, 2),
|
||||
})
|
||||
|
||||
# V-Fix: SQLAlchemy 2.0 需要使用 text()
|
||||
r1 = pd.read_sql(text(sql_m1), db.engine)
|
||||
r2 = pd.read_sql(text(sql_m2), db.engine)
|
||||
|
||||
v1 = float(r1['total'].iloc[0] or 0)
|
||||
v2 = float(r2['total'].iloc[0] or 0)
|
||||
|
||||
m_growth = ((v2 - v1) / v1 * 100) if v1 > 0 else (0 if v2 == 0 else 100)
|
||||
|
||||
monthly_breakdown.append({
|
||||
'month': m,
|
||||
'month_label': f'{m}月',
|
||||
'year1_value': v1,
|
||||
'year2_value': v2,
|
||||
'growth_rate': round(m_growth, 2)
|
||||
})
|
||||
|
||||
total_year1 = sum(year1_values.values())
|
||||
total_year2 = sum(year2_values.values())
|
||||
growth_rate = (
|
||||
((total_year2 - total_year1) / total_year1) * 100
|
||||
if total_year1
|
||||
else (100 if total_year2 else 0)
|
||||
)
|
||||
response = {
|
||||
'year1': {
|
||||
'label': f'{year1}年' + (f'{month}月' if month else ''),
|
||||
'total': total_year1
|
||||
'label': f'{year1}年',
|
||||
'total': total_year1,
|
||||
},
|
||||
'year2': {
|
||||
'label': f'{year2}年' + (f'{month}月' if month else ''),
|
||||
'total': total_year2
|
||||
'label': f'{year2}年',
|
||||
'total': total_year2,
|
||||
},
|
||||
'growth_rate': round(growth_rate, 2),
|
||||
'metric': metric,
|
||||
'metric_label': metric_label,
|
||||
'monthly_breakdown': monthly_breakdown
|
||||
'monthly_breakdown': monthly_breakdown,
|
||||
'period': {
|
||||
'linked': bool(start_value or end_value or (month_value and month_value != 'all')),
|
||||
'year1': {
|
||||
'start_date': year1_start.isoformat(),
|
||||
'end_date': year1_end.isoformat(),
|
||||
},
|
||||
'year2': {
|
||||
'start_date': year2_start.isoformat(),
|
||||
'end_date': year2_end.isoformat(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sys_log.info(f"[YoY] {year1} vs {year2}: {total_year1:,.0f} -> {total_year2:,.0f} ({growth_rate:+.1f}%)")
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except Exception as e:
|
||||
sys_log.error(f"[YoY] Error: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -821,18 +821,18 @@
|
||||
<table id="dataTable" class="table table-hover align-middle mb-0" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-center" style="width: 60px;">排名</th>
|
||||
<th style="width: 100px;">商品編號</th>
|
||||
<th style="width: 25%;">商品名稱</th>
|
||||
{% if cols.brand %}<th>品牌</th>{% endif %}
|
||||
{% if cols.vendor %}<th>廠商名稱</th>{% endif %}
|
||||
{% if cols.cat %}<th>分類</th>{% endif %}
|
||||
{% if cols.qty %}<th>平均單價</th>{% endif %}
|
||||
{% if cols.cost or cols.profit %}<th>毛利率</th>{% endif %}
|
||||
{% if cols.return_qty %}<th>退貨率</th>{% endif %}
|
||||
{% if cols.date %}<th>銷售月份</th>{% endif %}
|
||||
{% if cols.qty %}<th class="text-end">銷售數量</th>{% endif %}
|
||||
<th class="text-end">銷售金額</th>
|
||||
<th data-field="rank" class="text-center" style="width: 60px;">排名</th>
|
||||
<th data-field="product_id" style="width: 100px;">商品編號</th>
|
||||
<th data-field="name" style="width: 25%;">商品名稱</th>
|
||||
{% if cols.brand %}<th data-field="brand">品牌</th>{% endif %}
|
||||
{% if cols.vendor %}<th data-field="vendor">廠商名稱</th>{% endif %}
|
||||
{% if cols.cat %}<th data-field="category">分類</th>{% endif %}
|
||||
{% if cols.qty %}<th data-field="avg_price">平均單價</th>{% endif %}
|
||||
{% if cols.cost or cols.profit %}<th data-field="margin_rate">毛利率</th>{% endif %}
|
||||
{% if cols.return_qty %}<th data-field="return_rate">退貨率</th>{% endif %}
|
||||
{% if cols.date %}<th data-field="month_str">銷售月份</th>{% endif %}
|
||||
{% if cols.qty %}<th data-field="qty" class="text-end">銷售數量</th>{% endif %}
|
||||
<th data-field="amount" class="text-end">銷售金額</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
|
||||
@@ -14,7 +14,13 @@ from routes.monthly_routes import (
|
||||
_resolve_monthly_period,
|
||||
get_monthly_summary_data,
|
||||
)
|
||||
from routes.sales_routes import _filter_growth_payload, _sorted_valid_month_labels
|
||||
import routes.sales_routes as sales_routes
|
||||
from routes.sales_routes import (
|
||||
_filter_growth_payload,
|
||||
_period_month_keys,
|
||||
_project_yoy_period,
|
||||
_sorted_valid_month_labels,
|
||||
)
|
||||
from services.analysis_period_service import make_analysis_period, make_month_period
|
||||
|
||||
|
||||
@@ -284,10 +290,72 @@ def test_monthly_frontend_has_replayable_period_and_no_fixed_years():
|
||||
|
||||
def test_sales_frontend_uses_live_period_linked_api_routes():
|
||||
script = (ROOT / "web/static/js/page-sales-analysis.js").read_text(encoding="utf-8")
|
||||
template = (ROOT / "templates/sales_analysis.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "buildPeriodApiUrl" in script
|
||||
assert "new URLSearchParams(window.location.search)" in script
|
||||
assert "/api/sales_analysis/yoy_comparison" in script
|
||||
assert "/api/sales_analysis/table_data" in script
|
||||
assert "d.growth_rate" in script
|
||||
assert "d.monthly_breakdown" in script
|
||||
assert "columns," in script
|
||||
assert 'data-field="product_id"' in template
|
||||
assert 'data-field="amount"' in template
|
||||
assert "fetch(`/api/yoy?" not in script
|
||||
assert "'/api/sales_table?'" not in script
|
||||
|
||||
|
||||
def test_yoy_period_projects_selected_dates_to_each_comparison_year():
|
||||
start, end = _project_yoy_period(2025, "2026-04-01", "2026-04-30")
|
||||
|
||||
assert start == date(2025, 4, 1)
|
||||
assert end == date(2025, 4, 30)
|
||||
assert _period_month_keys(start, end) == ["2025-04"]
|
||||
|
||||
|
||||
def test_yoy_api_uses_only_the_selected_period_for_totals_and_chart(monkeypatch):
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
with engine.begin() as conn:
|
||||
conn.exec_driver_sql(
|
||||
'CREATE TABLE realtime_sales_monthly ("日期" TEXT, "總業績" REAL, "數量" REAL, "總成本" REAL)'
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO realtime_sales_monthly ("日期", "總業績", "數量", "總成本")
|
||||
VALUES
|
||||
('2025/04/10', 100, 2, 60),
|
||||
('2025/05/10', 900, 9, 100),
|
||||
('2026/04/10', 150, 3, 80),
|
||||
('2026/05/10', 1200, 12, 200)
|
||||
"""
|
||||
)
|
||||
|
||||
class FakeDatabaseManager:
|
||||
def __init__(self):
|
||||
self.engine = engine
|
||||
|
||||
monkeypatch.setattr(sales_routes, "DatabaseManager", FakeDatabaseManager)
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/api/sales_analysis/yoy_comparison"
|
||||
"?year1=2025&year2=2026&metric=revenue"
|
||||
"&start_date=2026-04-01&end_date=2026-04-30"
|
||||
):
|
||||
response = sales_routes.api_yoy_comparison.__wrapped__()
|
||||
|
||||
payload = response.get_json()
|
||||
assert payload["period"]["linked"] is True
|
||||
assert payload["period"]["year1"] == {
|
||||
"start_date": "2025-04-01",
|
||||
"end_date": "2025-04-30",
|
||||
}
|
||||
assert payload["year1"]["total"] == 100
|
||||
assert payload["year2"]["total"] == 150
|
||||
assert payload["growth_rate"] == 50
|
||||
assert payload["monthly_breakdown"] == [{
|
||||
"month": 4,
|
||||
"month_label": "4月",
|
||||
"year1_value": 100,
|
||||
"year2_value": 150,
|
||||
"growth_rate": 50,
|
||||
}]
|
||||
|
||||
@@ -488,11 +488,14 @@
|
||||
return response.json();
|
||||
})
|
||||
.then(d => {
|
||||
document.getElementById('yoy-year1-label').textContent = `${y1}年`;
|
||||
document.getElementById('yoy-year2-label').textContent = `${y2}年`;
|
||||
document.getElementById('yoy-year1-value').textContent = fmtMoney(d.total1 || 0);
|
||||
document.getElementById('yoy-year2-value').textContent = fmtMoney(d.total2 || 0);
|
||||
const growth = d.growth || 0;
|
||||
const breakdown = Array.isArray(d.monthly_breakdown) ? d.monthly_breakdown : [];
|
||||
const total1 = Number(d.year1 && d.year1.total || 0);
|
||||
const total2 = Number(d.year2 && d.year2.total || 0);
|
||||
document.getElementById('yoy-year1-label').textContent = d.year1 && d.year1.label || `${y1}年`;
|
||||
document.getElementById('yoy-year2-label').textContent = d.year2 && d.year2.label || `${y2}年`;
|
||||
document.getElementById('yoy-year1-value').textContent = fmtMoney(total1);
|
||||
document.getElementById('yoy-year2-value').textContent = fmtMoney(total2);
|
||||
const growth = Number(d.growth_rate || 0);
|
||||
const card = document.getElementById('yoy-growth-card');
|
||||
card.classList.toggle('is-decline', growth < 0);
|
||||
const arrow = growth >= 0 ? 'up' : 'down';
|
||||
@@ -504,11 +507,11 @@
|
||||
yoyChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: d.labels || [],
|
||||
labels: breakdown.map(row => row.month_label),
|
||||
datasets: [
|
||||
{ label: `${y1}年`, data: d.values1 || [], borderColor: MUTED,
|
||||
{ label: `${y1}年`, data: breakdown.map(row => Number(row.year1_value || 0)), borderColor: MUTED,
|
||||
backgroundColor: mix(MUTED, 8), tension: 0.3, fill: false, borderWidth: 2 },
|
||||
{ label: `${y2}年`, data: d.values2 || [], borderColor: ACCENT,
|
||||
{ label: `${y2}年`, data: breakdown.map(row => Number(row.year2_value || 0)), borderColor: ACCENT,
|
||||
backgroundColor: mix(ACCENT, 12), tension: 0.3, fill: true, borderWidth: 2.5 }
|
||||
]
|
||||
},
|
||||
@@ -530,8 +533,27 @@
|
||||
const $tbl = window.jQuery && jQuery('#dataTable');
|
||||
if (!$tbl || !$tbl.length || !jQuery.fn.DataTable) return;
|
||||
const url = buildPeriodApiUrl('/api/sales_analysis/table_data');
|
||||
const numericFields = new Set(['rank', 'avg_price', 'margin_rate', 'return_rate', 'qty', 'amount']);
|
||||
const columns = Array.from($tbl[0].querySelectorAll('thead th')).map((header) => {
|
||||
const field = header.dataset.field;
|
||||
return {
|
||||
data: field,
|
||||
defaultContent: '—',
|
||||
render(value, type) {
|
||||
if (type !== 'display') return numericFields.has(field) ? Number(value || 0) : value;
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
if (field === 'amount' || field === 'avg_price') return fmtMoney(value);
|
||||
if (field === 'margin_rate' || field === 'return_rate') return `${Number(value).toFixed(1)}%`;
|
||||
if (field === 'qty' || field === 'rank') return fmtNum(value);
|
||||
const node = document.createElement('span');
|
||||
node.textContent = String(value);
|
||||
return node.innerHTML;
|
||||
}
|
||||
};
|
||||
});
|
||||
$tbl.DataTable({
|
||||
ajax: { url, dataSrc: 'data' },
|
||||
columns,
|
||||
processing: true, serverSide: false,
|
||||
pageLength: 25, lengthMenu: [10, 25, 50, 100],
|
||||
order: [[$tbl.find('thead th').length - 1, 'desc']],
|
||||
|
||||
Reference in New Issue
Block a user