diff --git a/backend/app/api/products.py b/backend/app/api/products.py new file mode 100644 index 00000000..d607fe58 --- /dev/null +++ b/backend/app/api/products.py @@ -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, + } diff --git a/backend/app/main.py b/backend/app/main.py index 9dd4d5fd..5bc29f82 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 99663ba3..64ba8309 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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): diff --git a/backend/app/models/product_sales.py b/backend/app/models/product_sales.py new file mode 100644 index 00000000..d3a31100 --- /dev/null +++ b/backend/app/models/product_sales.py @@ -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()) diff --git a/backend/scripts/import_product_sales.py b/backend/scripts/import_product_sales.py new file mode 100644 index 00000000..83315f8d --- /dev/null +++ b/backend/scripts/import_product_sales.py @@ -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() diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 36cf8a71..a09689e7 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -28,6 +28,7 @@ const routes = [ { 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/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: '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'] } }, @@ -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: '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: '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 } }, ] }, diff --git a/frontend/src/views/ProductMatrix.vue b/frontend/src/views/ProductMatrix.vue new file mode 100644 index 00000000..7c1555ff --- /dev/null +++ b/frontend/src/views/ProductMatrix.vue @@ -0,0 +1,187 @@ + + + + +