Root cause: main.py include_router had no global auth dependency; user routers (products/cost/predict/...) relied on assert_entity_scope, which treats a missing session token as the Bot service channel and lets the request through. Result: 46 user endpoints returned data without any token on a public site. Changes: - auth_middleware.py: add require_auth_401 (missing Bearer -> 401, delegates to require_auth otherwise); 401/403 semantics are explicit. - router-level Depends(require_auth_401) on products, cost, predict, notifications, permissions, tenant, alignment; endpoint-level on /auth/roles and /admin/cache/clear. - deps.py: document that get_entity_id/assert_entity_scope are NOT auth. - frontend: add the missing Bearer header to 5 tokenless fetch/axios calls that hit the newly protected endpoints (KPIAlignment x2, RolePermissions x2, Dashboard x1). - tests: new test_api_auth_surface_20260914.py (58 cases: no-token 401, token 200, cross-entity 403, bot channel unchanged); adapt test_predict / test_kpi_forecast to send session tokens like production. - scripts/audit_api_auth_surface.py: reusable FastAPI dependency-graph audit (423 endpoints; A bot / B gap / C public), exits 1 when a B-class gap exists.
173 lines
6.8 KiB
Python
173 lines
6.8 KiB
Python
"""波士顿产品矩阵 API — 四象限分析"""
|
||
from fastapi import APIRouter, Depends, Query, Request
|
||
from typing import Optional
|
||
from sqlalchemy.orm import Session
|
||
from app.database import get_db
|
||
from app.deps import assert_entity_scope
|
||
from app.auth_middleware import require_auth_401
|
||
from app.models import ProductSales
|
||
from collections import defaultdict
|
||
|
||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||
# 无 token → 401(原「无 token 亦可取数」缺口收口);Bot 通道走 /api/cma/bot/*(X-BOT-KEY)不受影响。
|
||
router = APIRouter(prefix="/api/cma/products", tags=["产品矩阵"],
|
||
dependencies=[Depends(require_auth_401)])
|
||
|
||
|
||
def _calc_quadrant(trend: float, margin: float) -> str:
|
||
"""四象限分类:
|
||
横轴=近3月销售趋势(正=增长),纵轴=毛利率
|
||
明星(Star) = 高趋势+高毛利
|
||
现金牛(CashCow) = 低趋势+高毛利
|
||
问题(QuestionMark) = 高趋势+低毛利
|
||
瘦狗(Dog) = 低趋势+低毛利
|
||
"""
|
||
trend_high = trend >= 0
|
||
margin_high = margin >= 0
|
||
if trend_high and margin_high:
|
||
return "star"
|
||
if not trend_high and margin_high:
|
||
return "cash_cow"
|
||
if trend_high and not margin_high:
|
||
return "question_mark"
|
||
return "dog"
|
||
|
||
|
||
@router.get("/matrix")
|
||
def get_product_matrix(
|
||
request: Request,
|
||
entity_id: Optional[int] = Query(None, ge=1, description="实体ID(须与会话账套一致);缺省按会话账套"),
|
||
months: int = Query(3, ge=1, le=6, description="趋势计算月数"),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""产品矩阵:横轴=销售趋势,纵轴=毛利率,气泡=销售额
|
||
|
||
多租户(cma-cross-entity-endpoints-auth-20260914):
|
||
登录会话以 token 绑定账套为准,显式 entity_id 不一致 → 403(同 growth-quality 文案)。
|
||
"""
|
||
session_entity = assert_entity_scope(request, entity_id, db)
|
||
target_entity = session_entity if session_entity is not None else (entity_id or 1)
|
||
entity_id = target_entity
|
||
# 取最近 months+1 个月(多取1个月用于计算趋势)
|
||
periods = db.query(ProductSales.period_month).filter(
|
||
ProductSales.entity_id == entity_id
|
||
).distinct().order_by(ProductSales.period_month.desc()).limit(months + 1).all()
|
||
periods = sorted([p[0] for p in periods])
|
||
|
||
if len(periods) < 2:
|
||
return {
|
||
"entity_id": entity_id,
|
||
"has_data": False,
|
||
"message": "数据不足,至少需要2个月数据",
|
||
"quadrants": [],
|
||
"products": [],
|
||
}
|
||
|
||
trend_periods = periods[-months:] # 最近 months 个月
|
||
prev_periods = periods[:-months] if len(periods) > months else periods[:1]
|
||
|
||
# 加载数据
|
||
rows = db.query(ProductSales).filter(
|
||
ProductSales.entity_id == entity_id,
|
||
ProductSales.period_month.in_(periods),
|
||
).all()
|
||
|
||
# 按商品聚合
|
||
products = defaultdict(lambda: {
|
||
"code": "", "name": "", "months": {},
|
||
"total_sales": 0, "total_qty": 0, "total_gross": 0,
|
||
})
|
||
for row in rows:
|
||
p = products[row.product_code]
|
||
p["code"] = row.product_code
|
||
p["name"] = row.product_name
|
||
p["months"][row.period_month] = {
|
||
"sales": float(row.sales_amount or 0),
|
||
"margin": float(row.gross_margin_rate or 0),
|
||
"gross": float(row.gross_profit or 0),
|
||
"qty": int(row.sales_qty or 0),
|
||
}
|
||
p["total_sales"] += float(row.sales_amount or 0)
|
||
p["total_qty"] += int(row.sales_qty or 0)
|
||
p["total_gross"] += float(row.gross_profit or 0)
|
||
|
||
# 计算每个商品的趋势和毛利率
|
||
result_products = []
|
||
for code, p in products.items():
|
||
# 趋势 = 最近月份 vs 前一月的环比(取趋势期间的平均环比增速)
|
||
# 用最近3个月的销售序列做简单线性趋势
|
||
trend_sales = []
|
||
for pp in periods:
|
||
trend_sales.append(p["months"].get(pp, {}).get("sales", 0))
|
||
# 线性回归斜率(最小二乘)
|
||
n = len(trend_sales)
|
||
if n >= 2:
|
||
xs = list(range(n))
|
||
x_mean = sum(xs) / n
|
||
y_mean = sum(trend_sales) / n
|
||
numerator = sum((xs[i] - x_mean) * (trend_sales[i] - y_mean) for i in range(n))
|
||
denominator = sum((xs[i] - x_mean) ** 2 for i in range(n))
|
||
slope = numerator / denominator if denominator else 0
|
||
# 斜率转为百分比(相对期间平均销售)
|
||
avg = y_mean if y_mean != 0 else 1
|
||
trend = slope / abs(avg) * 100
|
||
else:
|
||
trend = 0.0
|
||
|
||
# 毛利率 = 加权平均(按销售额)
|
||
weighted_margin = 0.0
|
||
total_sales_for_margin = 0
|
||
for pp in trend_periods:
|
||
m = p["months"].get(pp)
|
||
if m and m["sales"] > 0:
|
||
weighted_margin += m["margin"] * m["sales"]
|
||
total_sales_for_margin += m["sales"]
|
||
if total_sales_for_margin > 0:
|
||
weighted_margin = weighted_margin / total_sales_for_margin
|
||
else:
|
||
# 无销售用平均毛利率
|
||
margins = [p["months"][pp]["margin"] for pp in p["months"] if p["months"][pp]["margin"] != 0]
|
||
weighted_margin = sum(margins) / len(margins) if margins else 0
|
||
|
||
quadrant = _calc_quadrant(trend, weighted_margin)
|
||
|
||
result_products.append({
|
||
"code": code,
|
||
"name": p["name"],
|
||
"total_sales": round(p["total_sales"], 2),
|
||
"total_qty": p["total_qty"],
|
||
"total_gross": round(p["total_gross"], 2),
|
||
"trend_pct": round(trend, 1),
|
||
"margin_pct": round(weighted_margin, 1),
|
||
"quadrant": quadrant,
|
||
})
|
||
|
||
# 按销售额排序
|
||
result_products.sort(key=lambda x: -x["total_sales"])
|
||
|
||
# 四象限汇总
|
||
quadrant_labels = {
|
||
"star": {"label": "明星产品", "icon": "🌟", "advice": "高增长+有毛利,重点主推,加大投入"},
|
||
"cash_cow": {"label": "现金牛", "icon": "🥇", "advice": "销量大但增长放缓,维持稳定产出"},
|
||
"question_mark": {"label": "问题产品", "icon": "❓", "advice": "增长好但毛利低,优化成本或提价"},
|
||
"dog": {"label": "瘦狗产品", "icon": "🐶", "advice": "低增长+低毛利,考虑清库存或停产"},
|
||
}
|
||
quadrants = []
|
||
for q in ["star", "cash_cow", "question_mark", "dog"]:
|
||
items = [p for p in result_products if p["quadrant"] == q]
|
||
quadrants.append({
|
||
"key": q,
|
||
**quadrant_labels[q],
|
||
"count": len(items),
|
||
"products": items,
|
||
})
|
||
|
||
return {
|
||
"entity_id": entity_id,
|
||
"has_data": True,
|
||
"periods": periods,
|
||
"months_analyzed": months,
|
||
"quadrants": quadrants,
|
||
"products": result_products,
|
||
}
|