feat: 波士顿产品矩阵 — 四象限分析(明星/现金牛/问题/瘦狗)
- 数据层: product_sales表+导入脚本(93条, 酣客1-8月商品销售排行榜) - 后端: GET /api/cma/products/matrix?entity_id&months 横轴=销售趋势(线性回归), 纵轴=加权毛利率, 气泡=销售额 - 前端: ProductMatrix.vue ECharts散点图+象限卡片+明细表, 支持酣客/博海切换 - 验证: API四象限分类正确, 数据联动, 前端路由200, 全部通过
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
"""波士顿产品矩阵 API — 四象限分析"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import ProductSales
|
||||
from collections import defaultdict
|
||||
|
||||
router = APIRouter(prefix="/api/cma/products", tags=["产品矩阵"])
|
||||
|
||||
|
||||
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(
|
||||
entity_id: int = Query(1, description="1=酣客 2=博海"),
|
||||
months: int = Query(3, ge=1, le=6, description="趋势计算月数"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""产品矩阵:横轴=销售趋势,纵轴=毛利率,气泡=销售额"""
|
||||
# 取最近 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,
|
||||
}
|
||||
+2
-1
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from dotenv import load_dotenv
|
||||
from app.database import init_db
|
||||
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality
|
||||
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products
|
||||
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||
from scripts.erp_sync import run_sync as run_erp_sync
|
||||
from app.auth_middleware import require_auth
|
||||
@@ -53,6 +53,7 @@ app.include_router(budget.router)
|
||||
app.include_router(cost.router)
|
||||
app.include_router(predict.router)
|
||||
app.include_router(growth_quality.router)
|
||||
app.include_router(products.router)
|
||||
app.include_router(reports.router)
|
||||
app.include_router(security.router)
|
||||
app.include_router(knowledge.router)
|
||||
|
||||
@@ -6,6 +6,7 @@ from app.models.budget_plan import BudgetPlan
|
||||
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
||||
from app.models.knowledge import KnowledgeEvent, KnowledgeSummary
|
||||
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
|
||||
from app.models.product_sales import ProductSales
|
||||
|
||||
|
||||
class Entity(Base):
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""商品销售数据模型 — 波士顿产品矩阵
|
||||
从《商品销售排行榜》Excel导入,支撑四象限分析
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ProductSales(Base):
|
||||
"""商品销售月度数据"""
|
||||
__tablename__ = "product_sales"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID: 1=酣客 2=博海")
|
||||
product_code = Column(String(50), nullable=False, comment="商品编码")
|
||||
product_name = Column(String(100), nullable=False, comment="商品名称")
|
||||
period_month = Column(String(10), nullable=False, comment="期间 YYYY-MM")
|
||||
sales_amount = Column(Float, default=0, comment="销售金额")
|
||||
cost_amount = Column(Float, default=0, comment="成本金额")
|
||||
gross_profit = Column(Float, default=0, comment="毛利")
|
||||
gross_margin_rate = Column(Float, default=0, comment="毛利率(%)")
|
||||
sales_qty = Column(Integer, default=0, comment="销售数量")
|
||||
unit = Column(String(30), default="", comment="单位")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""导入酣客1-8月商品销售排行榜到product_sales表"""
|
||||
import openpyxl
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import pymysql
|
||||
|
||||
# DB连接
|
||||
DB_CONFIG = {
|
||||
'host': '127.0.0.1',
|
||||
'user': 'cma_user',
|
||||
'password': 'cma_pass_2026',
|
||||
'database': 'cma',
|
||||
'charset': 'utf8mb4',
|
||||
}
|
||||
|
||||
# 8份排行榜文件(URL编码的路径)
|
||||
files = {
|
||||
'2026-01': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_7c0e51888b10_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B41%E6%9C%88%EF%BC%89.xlsx',
|
||||
'2026-02': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_309cee3d3d78_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B42%E6%9C%88%EF%BC%89.xlsx',
|
||||
'2026-03': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_fdc1228e7173_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B43%E6%9C%88%EF%BC%89.xlsx',
|
||||
'2026-04': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_da10cb16765c_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B44%E6%9C%88%EF%BC%89.xlsx',
|
||||
'2026-05': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_bda951a0f6e3_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B45%E6%9C%88%EF%BC%89.xlsx',
|
||||
'2026-06': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_6fcb80d2a02d_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B46%E6%9C%88%EF%BC%89.xlsx',
|
||||
'2026-07': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_a449da246d17_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B47%E6%9C%88%EF%BC%89.xlsx',
|
||||
'2026-08': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_ac2b807b1517_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B48%E6%9C%88%EF%BC%89.xlsx',
|
||||
}
|
||||
|
||||
def import_file(conn, cursor, period, path):
|
||||
"""导入单个月份Excel"""
|
||||
if not os.path.exists(path):
|
||||
print(f" ⚠️ 文件不存在: {period}")
|
||||
return 0
|
||||
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
count = 0
|
||||
for r in range(2, ws.max_row + 1):
|
||||
code = ws.cell(r, 2).value # 商品编码
|
||||
name = ws.cell(r, 3).value # 商品名称
|
||||
sales = ws.cell(r, 4).value # 销售金额
|
||||
cost = ws.cell(r, 6).value # 成本金额
|
||||
gross = ws.cell(r, 7).value # 毛利
|
||||
margin = ws.cell(r, 10).value # 毛利率(%)
|
||||
qty = ws.cell(r, 13).value # 销售数量
|
||||
unit = ws.cell(r, 14).value # 单位
|
||||
|
||||
# 跳过合计行(编码为空)和空行
|
||||
if code is None or str(code).strip() == '':
|
||||
continue
|
||||
if name is None or str(name).strip() == '':
|
||||
continue
|
||||
# 跳过汇总行(如"上期库存")
|
||||
if '上期' in str(name) or '合计' in str(name) or '总计' in str(name):
|
||||
continue
|
||||
|
||||
code = str(code).strip()
|
||||
name = str(name).strip()
|
||||
sales = float(sales or 0)
|
||||
cost = float(cost or 0)
|
||||
gross = float(gross or 0)
|
||||
margin = float(margin or 0)
|
||||
qty = int(qty or 0)
|
||||
unit = str(unit or '').strip()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO product_sales (entity_id, product_code, product_name, period_month,
|
||||
sales_amount, cost_amount, gross_profit, gross_margin_rate, sales_qty, unit)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
product_name=VALUES(product_name),
|
||||
sales_amount=VALUES(sales_amount),
|
||||
cost_amount=VALUES(cost_amount),
|
||||
gross_profit=VALUES(gross_profit),
|
||||
gross_margin_rate=VALUES(gross_margin_rate),
|
||||
sales_qty=VALUES(sales_qty),
|
||||
unit=VALUES(unit)
|
||||
""", (1, code, name, period, sales, cost, gross, margin, qty, unit))
|
||||
count += 1
|
||||
wb.close()
|
||||
return count
|
||||
|
||||
def main():
|
||||
conn = pymysql.connect(**DB_CONFIG)
|
||||
cursor = conn.cursor()
|
||||
total = 0
|
||||
for period, path in files.items():
|
||||
n = import_file(conn, cursor, period, path)
|
||||
print(f" {period}: {n}条")
|
||||
total += n
|
||||
conn.commit()
|
||||
print(f"\n✅ 共导入 {total} 条")
|
||||
|
||||
# 验证
|
||||
cursor.execute("SELECT period_month, COUNT(*), ROUND(SUM(sales_amount)) FROM product_sales GROUP BY period_month ORDER BY period_month")
|
||||
for row in cursor.fetchall():
|
||||
print(f" {row[0]}: {row[1]}商品, 销售合计{row[2]:,.0f}")
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user