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 fastapi.responses import JSONResponse
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from app.database import init_db
|
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 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 scripts.erp_sync import run_sync as run_erp_sync
|
||||||
from app.auth_middleware import require_auth
|
from app.auth_middleware import require_auth
|
||||||
@@ -53,6 +53,7 @@ app.include_router(budget.router)
|
|||||||
app.include_router(cost.router)
|
app.include_router(cost.router)
|
||||||
app.include_router(predict.router)
|
app.include_router(predict.router)
|
||||||
app.include_router(growth_quality.router)
|
app.include_router(growth_quality.router)
|
||||||
|
app.include_router(products.router)
|
||||||
app.include_router(reports.router)
|
app.include_router(reports.router)
|
||||||
app.include_router(security.router)
|
app.include_router(security.router)
|
||||||
app.include_router(knowledge.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.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
||||||
from app.models.knowledge import KnowledgeEvent, KnowledgeSummary
|
from app.models.knowledge import KnowledgeEvent, KnowledgeSummary
|
||||||
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
|
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
|
||||||
|
from app.models.product_sales import ProductSales
|
||||||
|
|
||||||
|
|
||||||
class Entity(Base):
|
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()
|
||||||
@@ -28,6 +28,7 @@ const routes = [
|
|||||||
{ path: 'cost', name: 'CostDashboard', component: () => import('@/views/CostDashboard.vue'), meta: { title: '成本分析', roles: ['ceo', 'finance', 'it'] } },
|
{ path: 'cost', name: 'CostDashboard', component: () => import('@/views/CostDashboard.vue'), meta: { title: '成本分析', roles: ['ceo', 'finance', 'it'] } },
|
||||||
{ path: 'predict', name: 'PredictDashboard', component: () => import('@/views/PredictDashboard.vue'), meta: { title: '预测模拟', roles: ['ceo', 'finance', 'it'] } },
|
{ path: 'predict', name: 'PredictDashboard', component: () => import('@/views/PredictDashboard.vue'), meta: { title: '预测模拟', roles: ['ceo', 'finance', 'it'] } },
|
||||||
{ path: 'predict/accuracy', name: 'PredictAccuracy', component: () => import('@/views/PredictAccuracy.vue'), meta: { title: '预测准确率', roles: ['ceo', 'finance', 'it'] } },
|
{ path: 'predict/accuracy', name: 'PredictAccuracy', component: () => import('@/views/PredictAccuracy.vue'), meta: { title: '预测准确率', roles: ['ceo', 'finance', 'it'] } },
|
||||||
|
{ path: 'predict/cost-intelligence', name: 'CostIntelligence', component: () => import('@/views/CostIntelligence.vue'), meta: { title: '预测性成本智能', roles: ['ceo', 'finance', 'it'] } },
|
||||||
{ path: 'real-options', name: 'RealOptions', component: () => import('@/views/RealOptions.vue'), meta: { title: '实物期权计算器', roles: ['ceo', 'finance'] } },
|
{ path: 'real-options', name: 'RealOptions', component: () => import('@/views/RealOptions.vue'), meta: { title: '实物期权计算器', roles: ['ceo', 'finance'] } },
|
||||||
{ path: 'action-plans', name: 'ActionPlans', component: () => import('@/views/ActionPlanLibrary.vue'), meta: { title: '改善行动', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
|
{ path: 'action-plans', name: 'ActionPlans', component: () => import('@/views/ActionPlanLibrary.vue'), meta: { title: '改善行动', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
|
||||||
{ path: 'reports', name: 'ReportCenter', component: () => import('@/views/ReportCenter.vue'), meta: { title: '管理报表', roles: ['ceo', 'finance', 'business'] } },
|
{ path: 'reports', name: 'ReportCenter', component: () => import('@/views/ReportCenter.vue'), meta: { title: '管理报表', roles: ['ceo', 'finance', 'business'] } },
|
||||||
@@ -45,6 +46,7 @@ const routes = [
|
|||||||
{ path: 'cash-plan', name: 'CashPlan', component: () => import('@/views/CashPlan.vue'), meta: { title: '收付款计划', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
|
{ path: 'cash-plan', name: 'CashPlan', component: () => import('@/views/CashPlan.vue'), meta: { title: '收付款计划', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
|
||||||
{ path: 'receivables', name: 'Receivables', component: () => import('@/views/Receivables.vue'), meta: { title: '应收款催收', roles: ['ceo', 'finance', 'business'] } },
|
{ path: 'receivables', name: 'Receivables', component: () => import('@/views/Receivables.vue'), meta: { title: '应收款催收', roles: ['ceo', 'finance', 'business'] } },
|
||||||
{ path: 'growth-quality', name: 'GrowthQuality', component: () => import('@/views/GrowthQuality.vue'), meta: { title: '增长质量诊断', roles: ['ceo', 'finance', 'business', 'it'] } },
|
{ path: 'growth-quality', name: 'GrowthQuality', component: () => import('@/views/GrowthQuality.vue'), meta: { title: '增长质量诊断', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||||
|
{ path: 'product-matrix', name: 'ProductMatrix', component: () => import('@/views/ProductMatrix.vue'), meta: { title: '波士顿产品矩阵', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||||
{ path: 'tax-compliance', name: 'TaxCompliance', component: () => import('@/views/TaxCompliance.vue'), meta: { title: '税务合规', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
|
{ path: 'tax-compliance', name: 'TaxCompliance', component: () => import('@/views/TaxCompliance.vue'), meta: { title: '税务合规', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<div class="product-matrix-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>🌟 波士顿产品矩阵</h2>
|
||||||
|
<div class="header-right">
|
||||||
|
<el-select v-model="entityId" size="small" style="width:130px" @change="loadData">
|
||||||
|
<el-option :value="1" label="酣客" />
|
||||||
|
<el-option :value="2" label="博海" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="months" size="small" style="width:140px" @change="loadData">
|
||||||
|
<el-option :value="3" label="近3月" />
|
||||||
|
<el-option :value="6" label="近6月" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-row :gutter="16" v-loading="loading">
|
||||||
|
<!-- 散点图 -->
|
||||||
|
<el-col :span="16">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>四象限散点图(气泡大小=销售额)</template>
|
||||||
|
<div ref="chartRef" style="height:440px"></div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- 图例说明 -->
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>象限说明</template>
|
||||||
|
<div v-if="!hasData" class="empty-tip">{{ message || '暂无数据' }}</div>
|
||||||
|
<div v-for="q in quadrants" :key="q.key" class="quadrant-item">
|
||||||
|
<div class="quadrant-title">
|
||||||
|
<span class="quadrant-icon">{{ q.icon }}</span>
|
||||||
|
<b>{{ q.label }}</b>
|
||||||
|
<el-tag size="small" type="info">{{ q.count }}个</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="quadrant-advice">{{ q.advice }}</div>
|
||||||
|
<div class="quadrant-products">
|
||||||
|
<el-tag v-for="p in q.products.slice(0,4)" :key="p.code" size="small" style="margin:2px">
|
||||||
|
{{ p.name }} {{ p.total_sales.toLocaleString() }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 明细表 -->
|
||||||
|
<el-row style="margin-top:16px">
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>商品明细(按销售额排序)</template>
|
||||||
|
<el-table :data="products" size="small">
|
||||||
|
<el-table-column prop="name" label="商品" width="120" />
|
||||||
|
<el-table-column prop="code" label="编码" width="100" />
|
||||||
|
<el-table-column label="象限" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="quadrantTag(row.quadrant)" size="small">
|
||||||
|
{{ quadrantIcon(row.quadrant) }} {{ quadrantName(row.quadrant) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="total_sales" label="累计销售" width="130" align="right">
|
||||||
|
<template #default="{ row }">{{ row.total_sales.toLocaleString() }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="total_qty" label="数量" width="80" align="right" />
|
||||||
|
<el-table-column prop="trend_pct" label="趋势%" width="90" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :style="{ color: row.trend_pct >= 0 ? '#67C23A' : '#F56C6C' }">{{ row.trend_pct }}%</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="margin_pct" label="毛利率%" width="90" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :style="{ color: row.margin_pct >= 0 ? '#67C23A' : '#F56C6C' }">{{ row.margin_pct }}%</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const entityId = ref<number>(Number(localStorage.getItem('cma_entity_id') || 1))
|
||||||
|
const months = ref(3)
|
||||||
|
const loading = ref(false)
|
||||||
|
const hasData = ref(false)
|
||||||
|
const message = ref('')
|
||||||
|
const quadrants = ref<any[]>([])
|
||||||
|
const products = ref<any[]>([])
|
||||||
|
const chartRef = ref<HTMLDivElement>()
|
||||||
|
|
||||||
|
const quadrantMeta: Record<string, { icon: string; name: string; tag: string; color: string }> = {
|
||||||
|
star: { icon: '🌟', name: '明星', tag: 'success', color: '#67C23A' },
|
||||||
|
cash_cow: { icon: '🥇', name: '现金牛', tag: 'primary', color: '#409EFF' },
|
||||||
|
question_mark: { icon: '❓', name: '问题', tag: 'warning', color: '#E6A23C' },
|
||||||
|
dog: { icon: '🐶', name: '瘦狗', tag: 'danger', color: '#909399' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function quadrantName(k: string) { return quadrantMeta[k]?.name || k }
|
||||||
|
function quadrantIcon(k: string) { return quadrantMeta[k]?.icon || '' }
|
||||||
|
function quadrantTag(k: string) { return quadrantMeta[k]?.tag || 'info' }
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const r = await api.get('/products/matrix', { params: { entity_id: entityId.value, months: months.value } })
|
||||||
|
const d = r.data || r
|
||||||
|
hasData.value = d.has_data
|
||||||
|
message.value = d.message || ''
|
||||||
|
quadrants.value = d.quadrants || []
|
||||||
|
products.value = d.products || []
|
||||||
|
renderChart()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载产品矩阵失败', e)
|
||||||
|
hasData.value = false
|
||||||
|
message.value = '加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart() {
|
||||||
|
if (!chartRef.value) return
|
||||||
|
const chart = echarts.init(chartRef.value)
|
||||||
|
const points = products.value.map(p => ({
|
||||||
|
name: p.name,
|
||||||
|
value: [p.trend_pct, p.margin_pct, p.total_sales],
|
||||||
|
symbolSize: Math.max(14, Math.min(60, Math.sqrt(p.total_sales) / 4)),
|
||||||
|
itemStyle: { color: quadrantMeta[p.quadrant]?.color || '#909399' },
|
||||||
|
}))
|
||||||
|
chart.setOption({
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const d = params.data
|
||||||
|
return `${d.name}<br/>趋势: ${d.value[0]}%<br/>毛利率: ${d.value[1]}%<br/>累计销售: ${d.value[2].toLocaleString()}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: { left: 60, right: 40, top: 30, bottom: 40 },
|
||||||
|
xAxis: {
|
||||||
|
name: '销售趋势(%) →',
|
||||||
|
type: 'value',
|
||||||
|
axisLine: { lineStyle: { color: '#ccc' } },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
name: '毛利率(%) →',
|
||||||
|
type: 'value',
|
||||||
|
axisLine: { lineStyle: { color: '#ccc' } },
|
||||||
|
},
|
||||||
|
// 象限分隔线
|
||||||
|
markLine: undefined,
|
||||||
|
series: [{
|
||||||
|
type: 'scatter',
|
||||||
|
data: points,
|
||||||
|
label: { show: true, formatter: (p: any) => p.name, position: 'top', fontSize: 10 },
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (chartRef.value) {
|
||||||
|
const chart = echarts.getInstanceByDom(chartRef.value)
|
||||||
|
chart?.resize()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.product-matrix-page { padding: 16px; }
|
||||||
|
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||||
|
.page-header h2 { margin: 0; }
|
||||||
|
.header-right { display: flex; gap: 8px; }
|
||||||
|
.quadrant-item { margin-bottom: 12px; padding: 10px; border-radius: 8px; background: #f7f8fa; }
|
||||||
|
.quadrant-title { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||||
|
.quadrant-advice { font-size: 12px; color: #666; margin-bottom: 6px; }
|
||||||
|
.quadrant-products { display: flex; flex-wrap: wrap; }
|
||||||
|
.empty-tip { color: #999; text-align: center; padding: 20px 0; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user