Initial commit: 智能项目定价模型
This commit is contained in:
5
后端服务/tests/__init__.py
Normal file
5
后端服务/tests/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""智能项目定价模型 - 测试模块
|
||||
|
||||
遵循瑞小美系统技术栈标准
|
||||
测试框架: pytest + pytest-asyncio
|
||||
"""
|
||||
349
后端服务/tests/conftest.py
Normal file
349
后端服务/tests/conftest.py
Normal file
@@ -0,0 +1,349 @@
|
||||
"""pytest 测试配置
|
||||
|
||||
提供测试所需的 fixtures:
|
||||
- 测试数据库会话
|
||||
- 测试客户端
|
||||
- 测试数据工厂
|
||||
|
||||
遵循瑞小美系统技术栈标准
|
||||
"""
|
||||
|
||||
import os
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
# 设置测试环境变量
|
||||
os.environ["APP_ENV"] = "test"
|
||||
os.environ["DEBUG"] = "true"
|
||||
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
from app.main import app
|
||||
from app.database import Base, get_db
|
||||
from app.models import (
|
||||
Category, Material, Equipment, StaffLevel, FixedCost,
|
||||
Project, ProjectCostItem, ProjectLaborCost, ProjectCostSummary,
|
||||
Competitor, CompetitorPrice, BenchmarkPrice, MarketAnalysisResult,
|
||||
PricingPlan, ProfitSimulation, SensitivityAnalysis
|
||||
)
|
||||
|
||||
|
||||
# 测试数据库引擎(使用 SQLite 内存数据库)
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
test_engine = create_async_engine(
|
||||
TEST_DATABASE_URL,
|
||||
echo=False,
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
test_session_maker = async_sessionmaker(
|
||||
test_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""获取测试数据库会话
|
||||
|
||||
每个测试函数使用独立的数据库会话,测试后自动回滚
|
||||
"""
|
||||
# 创建表
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with test_session_maker() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.rollback()
|
||||
await session.close()
|
||||
|
||||
# 清理表
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""获取测试客户端
|
||||
|
||||
使用测试数据库会话替换应用的数据库依赖
|
||||
"""
|
||||
async def override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ============ 测试数据工厂 ============
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sample_category(db_session: AsyncSession) -> Category:
|
||||
"""创建示例项目分类"""
|
||||
category = Category(
|
||||
category_name="光电类",
|
||||
parent_id=None,
|
||||
sort_order=1,
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(category)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(category)
|
||||
return category
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sample_material(db_session: AsyncSession) -> Material:
|
||||
"""创建示例耗材"""
|
||||
material = Material(
|
||||
material_code="MAT001",
|
||||
material_name="冷凝胶",
|
||||
unit="ml",
|
||||
unit_price=Decimal("2.00"),
|
||||
supplier="供应商A",
|
||||
material_type="consumable",
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(material)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(material)
|
||||
return material
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sample_equipment(db_session: AsyncSession) -> Equipment:
|
||||
"""创建示例设备"""
|
||||
equipment = Equipment(
|
||||
equipment_code="EQP001",
|
||||
equipment_name="光子仪",
|
||||
original_value=Decimal("100000.00"),
|
||||
residual_rate=Decimal("5.00"),
|
||||
service_years=5,
|
||||
estimated_uses=2000,
|
||||
depreciation_per_use=Decimal("47.50"), # (100000 - 5000) / 2000
|
||||
purchase_date=datetime(2025, 1, 1).date(),
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(equipment)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(equipment)
|
||||
return equipment
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sample_staff_level(db_session: AsyncSession) -> StaffLevel:
|
||||
"""创建示例人员级别"""
|
||||
staff_level = StaffLevel(
|
||||
level_code="L2",
|
||||
level_name="中级美容师",
|
||||
hourly_rate=Decimal("50.00"),
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(staff_level)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(staff_level)
|
||||
return staff_level
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sample_fixed_cost(db_session: AsyncSession) -> FixedCost:
|
||||
"""创建示例固定成本"""
|
||||
fixed_cost = FixedCost(
|
||||
cost_name="房租",
|
||||
cost_type="rent",
|
||||
monthly_amount=Decimal("30000.00"),
|
||||
year_month=datetime.now().strftime("%Y-%m"),
|
||||
allocation_method="count",
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(fixed_cost)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(fixed_cost)
|
||||
return fixed_cost
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sample_project(
|
||||
db_session: AsyncSession,
|
||||
sample_category: Category
|
||||
) -> Project:
|
||||
"""创建示例服务项目"""
|
||||
project = Project(
|
||||
project_code="PRJ001",
|
||||
project_name="光子嫩肤",
|
||||
category_id=sample_category.id,
|
||||
description="IPL光子嫩肤项目",
|
||||
duration_minutes=60,
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(project)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sample_project_with_costs(
|
||||
db_session: AsyncSession,
|
||||
sample_project: Project,
|
||||
sample_material: Material,
|
||||
sample_equipment: Equipment,
|
||||
sample_staff_level: StaffLevel,
|
||||
sample_fixed_cost: FixedCost,
|
||||
) -> Project:
|
||||
"""创建带成本明细的示例项目"""
|
||||
# 添加耗材成本
|
||||
cost_item = ProjectCostItem(
|
||||
project_id=sample_project.id,
|
||||
item_type="material",
|
||||
item_id=sample_material.id,
|
||||
quantity=Decimal("20"),
|
||||
unit_cost=Decimal("2.00"),
|
||||
total_cost=Decimal("40.00"),
|
||||
)
|
||||
db_session.add(cost_item)
|
||||
|
||||
# 添加设备折旧成本
|
||||
equip_cost = ProjectCostItem(
|
||||
project_id=sample_project.id,
|
||||
item_type="equipment",
|
||||
item_id=sample_equipment.id,
|
||||
quantity=Decimal("1"),
|
||||
unit_cost=Decimal("47.50"),
|
||||
total_cost=Decimal("47.50"),
|
||||
)
|
||||
db_session.add(equip_cost)
|
||||
|
||||
# 添加人工成本
|
||||
labor_cost = ProjectLaborCost(
|
||||
project_id=sample_project.id,
|
||||
staff_level_id=sample_staff_level.id,
|
||||
duration_minutes=60,
|
||||
hourly_rate=Decimal("50.00"),
|
||||
labor_cost=Decimal("50.00"), # 60分钟 / 60 * 50
|
||||
)
|
||||
db_session.add(labor_cost)
|
||||
|
||||
await db_session.commit()
|
||||
await db_session.refresh(sample_project)
|
||||
return sample_project
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sample_competitor(db_session: AsyncSession) -> Competitor:
|
||||
"""创建示例竞品机构"""
|
||||
competitor = Competitor(
|
||||
competitor_name="美丽人生医美",
|
||||
address="XX市XX路100号",
|
||||
distance_km=Decimal("2.5"),
|
||||
positioning="medium",
|
||||
contact="13800138000",
|
||||
is_key_competitor=True,
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(competitor)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(competitor)
|
||||
return competitor
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sample_competitor_price(
|
||||
db_session: AsyncSession,
|
||||
sample_competitor: Competitor,
|
||||
sample_project: Project,
|
||||
) -> CompetitorPrice:
|
||||
"""创建示例竞品价格"""
|
||||
price = CompetitorPrice(
|
||||
competitor_id=sample_competitor.id,
|
||||
project_id=sample_project.id,
|
||||
project_name="光子嫩肤",
|
||||
original_price=Decimal("680.00"),
|
||||
promo_price=Decimal("480.00"),
|
||||
member_price=Decimal("580.00"),
|
||||
price_source="meituan",
|
||||
collected_at=datetime.now().date(),
|
||||
)
|
||||
db_session.add(price)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(price)
|
||||
return price
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sample_pricing_plan(
|
||||
db_session: AsyncSession,
|
||||
sample_project: Project,
|
||||
) -> PricingPlan:
|
||||
"""创建示例定价方案"""
|
||||
plan = PricingPlan(
|
||||
project_id=sample_project.id,
|
||||
plan_name="2026年Q1定价",
|
||||
strategy_type="profit",
|
||||
base_cost=Decimal("280.50"),
|
||||
target_margin=Decimal("50.00"),
|
||||
suggested_price=Decimal("561.00"),
|
||||
final_price=Decimal("580.00"),
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(plan)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(plan)
|
||||
return plan
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sample_cost_summary(
|
||||
db_session: AsyncSession,
|
||||
sample_project: Project,
|
||||
) -> ProjectCostSummary:
|
||||
"""创建示例成本汇总"""
|
||||
cost_summary = ProjectCostSummary(
|
||||
project_id=sample_project.id,
|
||||
material_cost=Decimal("100.00"),
|
||||
equipment_cost=Decimal("50.00"),
|
||||
labor_cost=Decimal("50.00"),
|
||||
fixed_cost_allocation=Decimal("30.00"),
|
||||
total_cost=Decimal("230.00"),
|
||||
calculated_at=datetime.now(), # 确保设置计算时间
|
||||
)
|
||||
db_session.add(cost_summary)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(cost_summary)
|
||||
return cost_summary
|
||||
|
||||
|
||||
# ============ 辅助函数 ============
|
||||
|
||||
def assert_response_success(response, expected_code=0):
|
||||
"""断言响应成功"""
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["code"] == expected_code
|
||||
assert "data" in data
|
||||
return data["data"]
|
||||
|
||||
|
||||
def assert_response_error(response, expected_code):
|
||||
"""断言响应错误"""
|
||||
data = response.json()
|
||||
assert data["code"] == expected_code
|
||||
return data
|
||||
1
后端服务/tests/test_api/__init__.py
Normal file
1
后端服务/tests/test_api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API 层集成测试"""
|
||||
107
后端服务/tests/test_api/test_categories.py
Normal file
107
后端服务/tests/test_api/test_categories.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""项目分类接口测试"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models import Category
|
||||
from tests.conftest import assert_response_success
|
||||
|
||||
|
||||
class TestCategoriesAPI:
|
||||
"""项目分类 API 测试"""
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_category(self, client: AsyncClient):
|
||||
"""测试创建分类"""
|
||||
response = await client.post(
|
||||
"/api/v1/categories",
|
||||
json={
|
||||
"category_name": "测试分类",
|
||||
"sort_order": 1,
|
||||
"is_active": True
|
||||
}
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["category_name"] == "测试分类"
|
||||
assert data["sort_order"] == 1
|
||||
assert data["is_active"] is True
|
||||
assert "id" in data
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_categories_list(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_category: Category
|
||||
):
|
||||
"""测试获取分类列表"""
|
||||
response = await client.get("/api/v1/categories")
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert "items" in data
|
||||
assert data["total"] >= 1
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_category_by_id(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_category: Category
|
||||
):
|
||||
"""测试获取单个分类"""
|
||||
response = await client.get(f"/api/v1/categories/{sample_category.id}")
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["id"] == sample_category.id
|
||||
assert data["category_name"] == sample_category.category_name
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_category(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_category: Category
|
||||
):
|
||||
"""测试更新分类"""
|
||||
response = await client.put(
|
||||
f"/api/v1/categories/{sample_category.id}",
|
||||
json={
|
||||
"category_name": "更新后分类",
|
||||
"sort_order": 99
|
||||
}
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["category_name"] == "更新后分类"
|
||||
assert data["sort_order"] == 99
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_category(self, client: AsyncClient):
|
||||
"""测试删除分类"""
|
||||
# 先创建
|
||||
create_response = await client.post(
|
||||
"/api/v1/categories",
|
||||
json={"category_name": "待删除分类"}
|
||||
)
|
||||
created = assert_response_success(create_response)
|
||||
|
||||
# 删除
|
||||
delete_response = await client.delete(
|
||||
f"/api/v1/categories/{created['id']}"
|
||||
)
|
||||
|
||||
assert delete_response.status_code == 200
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_category(self, client: AsyncClient):
|
||||
"""测试获取不存在的分类"""
|
||||
response = await client.get("/api/v1/categories/99999")
|
||||
|
||||
# API 返回 HTTP 404 + 错误详情
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert data["detail"]["code"] == 10002 # 数据不存在
|
||||
31
后端服务/tests/test_api/test_health.py
Normal file
31
后端服务/tests/test_api/test_health.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""健康检查接口测试"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
class TestHealthAPI:
|
||||
"""健康检查 API 测试"""
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check(self, client: AsyncClient):
|
||||
"""测试健康检查端点"""
|
||||
response = await client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["code"] == 0
|
||||
assert data["data"]["status"] == "healthy"
|
||||
assert "version" in data["data"]
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_root_endpoint(self, client: AsyncClient):
|
||||
"""测试根路径端点"""
|
||||
response = await client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["code"] == 0
|
||||
assert "智能项目定价模型" in data["data"]["name"]
|
||||
113
后端服务/tests/test_api/test_market.py
Normal file
113
后端服务/tests/test_api/test_market.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""市场行情接口测试"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models import Project, Competitor, CompetitorPrice
|
||||
from tests.conftest import assert_response_success
|
||||
|
||||
|
||||
class TestMarketAPI:
|
||||
"""市场行情 API 测试"""
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_competitor(self, client: AsyncClient):
|
||||
"""测试创建竞品机构"""
|
||||
response = await client.post(
|
||||
"/api/v1/competitors",
|
||||
json={
|
||||
"competitor_name": "测试竞品",
|
||||
"address": "测试地址",
|
||||
"distance_km": 3.5,
|
||||
"positioning": "medium",
|
||||
"is_key_competitor": True
|
||||
}
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["competitor_name"] == "测试竞品"
|
||||
assert float(data["distance_km"]) == 3.5
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_competitors_list(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_competitor: Competitor
|
||||
):
|
||||
"""测试获取竞品列表"""
|
||||
response = await client.get("/api/v1/competitors")
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert "items" in data
|
||||
assert data["total"] >= 1
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_competitor_price(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_competitor: Competitor,
|
||||
sample_project: Project
|
||||
):
|
||||
"""测试添加竞品价格"""
|
||||
response = await client.post(
|
||||
f"/api/v1/competitors/{sample_competitor.id}/prices",
|
||||
json={
|
||||
"project_id": sample_project.id,
|
||||
"project_name": "光子嫩肤",
|
||||
"original_price": 800.00,
|
||||
"promo_price": 600.00,
|
||||
"price_source": "meituan",
|
||||
"collected_at": "2026-01-20"
|
||||
}
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert float(data["original_price"]) == 800.00
|
||||
assert float(data["promo_price"]) == 600.00
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_market_analysis(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_project: Project,
|
||||
sample_competitor_price: CompetitorPrice
|
||||
):
|
||||
"""测试市场分析"""
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{sample_project.id}/market-analysis",
|
||||
json={
|
||||
"include_benchmark": False
|
||||
}
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["project_id"] == sample_project.id
|
||||
assert "price_statistics" in data
|
||||
assert "suggested_range" in data
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_market_analysis(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_project: Project,
|
||||
sample_competitor_price: CompetitorPrice
|
||||
):
|
||||
"""测试获取市场分析结果"""
|
||||
# 先执行分析
|
||||
await client.post(
|
||||
f"/api/v1/projects/{sample_project.id}/market-analysis",
|
||||
json={"include_benchmark": False}
|
||||
)
|
||||
|
||||
# 获取结果
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{sample_project.id}/market-analysis"
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["project_id"] == sample_project.id
|
||||
106
后端服务/tests/test_api/test_materials.py
Normal file
106
后端服务/tests/test_api/test_materials.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""耗材管理接口测试"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models import Material
|
||||
from tests.conftest import assert_response_success
|
||||
|
||||
|
||||
class TestMaterialsAPI:
|
||||
"""耗材管理 API 测试"""
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_material(self, client: AsyncClient):
|
||||
"""测试创建耗材"""
|
||||
response = await client.post(
|
||||
"/api/v1/materials",
|
||||
json={
|
||||
"material_code": "MAT_TEST001",
|
||||
"material_name": "测试耗材",
|
||||
"unit": "个",
|
||||
"unit_price": 10.50,
|
||||
"supplier": "测试供应商",
|
||||
"material_type": "consumable"
|
||||
}
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["material_code"] == "MAT_TEST001"
|
||||
assert data["material_name"] == "测试耗材"
|
||||
assert float(data["unit_price"]) == 10.50
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_materials_list(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_material: Material
|
||||
):
|
||||
"""测试获取耗材列表"""
|
||||
response = await client.get("/api/v1/materials")
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert "items" in data
|
||||
assert data["total"] >= 1
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_materials_with_filter(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_material: Material
|
||||
):
|
||||
"""测试带筛选的耗材列表"""
|
||||
response = await client.get(
|
||||
"/api/v1/materials",
|
||||
params={"material_type": "consumable"}
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["total"] >= 1
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_material(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_material: Material
|
||||
):
|
||||
"""测试更新耗材"""
|
||||
response = await client.put(
|
||||
f"/api/v1/materials/{sample_material.id}",
|
||||
json={
|
||||
"unit_price": 3.00,
|
||||
"supplier": "新供应商"
|
||||
}
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert float(data["unit_price"]) == 3.00
|
||||
assert data["supplier"] == "新供应商"
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_duplicate_material_code(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_material: Material
|
||||
):
|
||||
"""测试创建重复编码的耗材"""
|
||||
response = await client.post(
|
||||
"/api/v1/materials",
|
||||
json={
|
||||
"material_code": sample_material.material_code,
|
||||
"material_name": "重复编码耗材",
|
||||
"unit": "个",
|
||||
"unit_price": 10.00,
|
||||
"material_type": "consumable"
|
||||
}
|
||||
)
|
||||
|
||||
# API 返回 HTTP 400 + 错误详情
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["detail"]["code"] == 10003 # 数据已存在
|
||||
134
后端服务/tests/test_api/test_pricing.py
Normal file
134
后端服务/tests/test_api/test_pricing.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""智能定价接口测试"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
|
||||
from app.models import Project, ProjectCostSummary, PricingPlan
|
||||
from tests.conftest import assert_response_success
|
||||
|
||||
|
||||
class TestPricingAPI:
|
||||
"""智能定价 API 测试"""
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pricing_plans_list(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_pricing_plan: PricingPlan
|
||||
):
|
||||
"""测试获取定价方案列表"""
|
||||
response = await client.get("/api/v1/pricing-plans")
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert "items" in data
|
||||
assert data["total"] >= 1
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pricing_plan_detail(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_pricing_plan: PricingPlan
|
||||
):
|
||||
"""测试获取定价方案详情"""
|
||||
response = await client.get(
|
||||
f"/api/v1/pricing-plans/{sample_pricing_plan.id}"
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["id"] == sample_pricing_plan.id
|
||||
assert data["plan_name"] == sample_pricing_plan.plan_name
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pricing_plan(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
db_session,
|
||||
sample_project: Project
|
||||
):
|
||||
"""测试创建定价方案"""
|
||||
# 先添加成本汇总
|
||||
cost_summary = ProjectCostSummary(
|
||||
project_id=sample_project.id,
|
||||
material_cost=Decimal("100"),
|
||||
equipment_cost=Decimal("50"),
|
||||
labor_cost=Decimal("50"),
|
||||
fixed_cost_allocation=Decimal("0"),
|
||||
total_cost=Decimal("200"),
|
||||
calculated_at=datetime.now()
|
||||
)
|
||||
db_session.add(cost_summary)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/pricing-plans",
|
||||
json={
|
||||
"project_id": sample_project.id,
|
||||
"plan_name": "测试定价方案",
|
||||
"strategy_type": "profit",
|
||||
"target_margin": 50
|
||||
}
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["project_id"] == sample_project.id
|
||||
assert data["plan_name"] == "测试定价方案"
|
||||
assert data["strategy_type"] == "profit"
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pricing_plan(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_pricing_plan: PricingPlan
|
||||
):
|
||||
"""测试更新定价方案"""
|
||||
response = await client.put(
|
||||
f"/api/v1/pricing-plans/{sample_pricing_plan.id}",
|
||||
json={
|
||||
"final_price": 599.00,
|
||||
"plan_name": "更新后方案"
|
||||
}
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert float(data["final_price"]) == 599.00
|
||||
assert data["plan_name"] == "更新后方案"
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_simulate_strategy(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
db_session,
|
||||
sample_project: Project
|
||||
):
|
||||
"""测试策略模拟"""
|
||||
# 添加成本汇总
|
||||
cost_summary = ProjectCostSummary(
|
||||
project_id=sample_project.id,
|
||||
total_cost=Decimal("200"),
|
||||
material_cost=Decimal("100"),
|
||||
equipment_cost=Decimal("50"),
|
||||
labor_cost=Decimal("50"),
|
||||
fixed_cost_allocation=Decimal("0"),
|
||||
calculated_at=datetime.now()
|
||||
)
|
||||
db_session.add(cost_summary)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{sample_project.id}/simulate-strategy",
|
||||
json={
|
||||
"strategies": ["traffic", "profit", "premium"],
|
||||
"target_margin": 50
|
||||
}
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["project_id"] == sample_project.id
|
||||
assert len(data["results"]) == 3
|
||||
133
后端服务/tests/test_api/test_profit.py
Normal file
133
后端服务/tests/test_api/test_profit.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""利润模拟接口测试"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models import PricingPlan
|
||||
from tests.conftest import assert_response_success
|
||||
|
||||
|
||||
class TestProfitAPI:
|
||||
"""利润模拟 API 测试"""
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_simulate_profit(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_pricing_plan: PricingPlan
|
||||
):
|
||||
"""测试利润模拟"""
|
||||
response = await client.post(
|
||||
f"/api/v1/pricing-plans/{sample_pricing_plan.id}/simulate-profit",
|
||||
json={
|
||||
"price": 580.00,
|
||||
"estimated_volume": 100,
|
||||
"period_type": "monthly"
|
||||
}
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["pricing_plan_id"] == sample_pricing_plan.id
|
||||
assert "input" in data
|
||||
assert "result" in data
|
||||
assert "breakeven_analysis" in data
|
||||
assert data["input"]["price"] == 580.00
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_profit_simulations_list(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_pricing_plan: PricingPlan
|
||||
):
|
||||
"""测试获取模拟列表"""
|
||||
# 先创建一个模拟
|
||||
await client.post(
|
||||
f"/api/v1/pricing-plans/{sample_pricing_plan.id}/simulate-profit",
|
||||
json={
|
||||
"price": 580.00,
|
||||
"estimated_volume": 100,
|
||||
"period_type": "monthly"
|
||||
}
|
||||
)
|
||||
|
||||
response = await client.get("/api/v1/profit-simulations")
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert "items" in data
|
||||
assert data["total"] >= 1
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_sensitivity_analysis(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_pricing_plan: PricingPlan
|
||||
):
|
||||
"""测试敏感性分析"""
|
||||
# 先创建模拟
|
||||
sim_response = await client.post(
|
||||
f"/api/v1/pricing-plans/{sample_pricing_plan.id}/simulate-profit",
|
||||
json={
|
||||
"price": 580.00,
|
||||
"estimated_volume": 100,
|
||||
"period_type": "monthly"
|
||||
}
|
||||
)
|
||||
sim_data = assert_response_success(sim_response)
|
||||
|
||||
# 执行敏感性分析
|
||||
response = await client.post(
|
||||
f"/api/v1/profit-simulations/{sim_data['simulation_id']}/sensitivity",
|
||||
json={
|
||||
"price_change_rates": [-20, -10, 0, 10, 20]
|
||||
}
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert len(data["sensitivity_results"]) == 5
|
||||
assert "insights" in data
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_breakeven_analysis(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_pricing_plan: PricingPlan
|
||||
):
|
||||
"""测试盈亏平衡分析"""
|
||||
response = await client.get(
|
||||
f"/api/v1/pricing-plans/{sample_pricing_plan.id}/breakeven"
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["pricing_plan_id"] == sample_pricing_plan.id
|
||||
assert "breakeven_volume" in data
|
||||
assert "current_margin" in data
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_simulation(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_pricing_plan: PricingPlan
|
||||
):
|
||||
"""测试删除模拟"""
|
||||
# 创建模拟
|
||||
sim_response = await client.post(
|
||||
f"/api/v1/pricing-plans/{sample_pricing_plan.id}/simulate-profit",
|
||||
json={
|
||||
"price": 580.00,
|
||||
"estimated_volume": 100,
|
||||
"period_type": "monthly"
|
||||
}
|
||||
)
|
||||
sim_data = assert_response_success(sim_response)
|
||||
|
||||
# 删除
|
||||
delete_response = await client.delete(
|
||||
f"/api/v1/profit-simulations/{sim_data['simulation_id']}"
|
||||
)
|
||||
|
||||
assert delete_response.status_code == 200
|
||||
152
后端服务/tests/test_api/test_projects.py
Normal file
152
后端服务/tests/test_api/test_projects.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""服务项目接口测试"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models import Project, Category, Material, Equipment, StaffLevel
|
||||
from tests.conftest import assert_response_success
|
||||
|
||||
|
||||
class TestProjectsAPI:
|
||||
"""服务项目 API 测试"""
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_category: Category
|
||||
):
|
||||
"""测试创建项目"""
|
||||
response = await client.post(
|
||||
"/api/v1/projects",
|
||||
json={
|
||||
"project_code": "PRJ_TEST001",
|
||||
"project_name": "测试项目",
|
||||
"category_id": sample_category.id,
|
||||
"description": "测试项目描述",
|
||||
"duration_minutes": 45,
|
||||
"is_active": True
|
||||
}
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["project_code"] == "PRJ_TEST001"
|
||||
assert data["project_name"] == "测试项目"
|
||||
assert data["duration_minutes"] == 45
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_projects_list(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_project: Project
|
||||
):
|
||||
"""测试获取项目列表"""
|
||||
response = await client.get("/api/v1/projects")
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert "items" in data
|
||||
assert data["total"] >= 1
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_detail(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_project: Project
|
||||
):
|
||||
"""测试获取项目详情"""
|
||||
response = await client.get(f"/api/v1/projects/{sample_project.id}")
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["id"] == sample_project.id
|
||||
assert data["project_name"] == sample_project.project_name
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_cost_item(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_project: Project,
|
||||
sample_material: Material
|
||||
):
|
||||
"""测试添加成本明细"""
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{sample_project.id}/cost-items",
|
||||
json={
|
||||
"item_type": "material",
|
||||
"item_id": sample_material.id,
|
||||
"quantity": 5,
|
||||
"remark": "测试备注"
|
||||
}
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["item_type"] == "material"
|
||||
assert float(data["quantity"]) == 5.0
|
||||
assert float(data["total_cost"]) == 10.0 # 5 * 2.00
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_labor_cost(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_project: Project,
|
||||
sample_staff_level: StaffLevel
|
||||
):
|
||||
"""测试添加人工成本"""
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{sample_project.id}/labor-costs",
|
||||
json={
|
||||
"staff_level_id": sample_staff_level.id,
|
||||
"duration_minutes": 30
|
||||
}
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["duration_minutes"] == 30
|
||||
assert float(data["labor_cost"]) == 25.0 # 30/60 * 50
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_project_cost(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_project_with_costs: Project
|
||||
):
|
||||
"""测试计算项目成本"""
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{sample_project_with_costs.id}/calculate-cost",
|
||||
json={"allocation_method": "count"}
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["project_id"] == sample_project_with_costs.id
|
||||
assert "cost_breakdown" in data
|
||||
assert "total_cost" in data
|
||||
assert data["total_cost"] > 0
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cost_summary(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
sample_project_with_costs: Project
|
||||
):
|
||||
"""测试获取成本汇总"""
|
||||
# 先计算成本
|
||||
await client.post(
|
||||
f"/api/v1/projects/{sample_project_with_costs.id}/calculate-cost",
|
||||
json={"allocation_method": "count"}
|
||||
)
|
||||
|
||||
# 获取汇总
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{sample_project_with_costs.id}/cost-summary"
|
||||
)
|
||||
|
||||
data = assert_response_success(response)
|
||||
assert data["project_id"] == sample_project_with_costs.id
|
||||
assert "material_cost" in data
|
||||
assert "total_cost" in data
|
||||
1
后端服务/tests/test_services/__init__.py
Normal file
1
后端服务/tests/test_services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""服务层单元测试"""
|
||||
415
后端服务/tests/test_services/test_cost_service.py
Normal file
415
后端服务/tests/test_services/test_cost_service.py
Normal file
@@ -0,0 +1,415 @@
|
||||
"""成本计算服务单元测试
|
||||
|
||||
测试 CostService 的核心业务逻辑
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.cost_service import CostService
|
||||
from app.schemas.project_cost import AllocationMethod, CostItemType
|
||||
from app.models import (
|
||||
Material, Equipment, StaffLevel, Project, FixedCost,
|
||||
ProjectCostItem, ProjectLaborCost, ProjectCostSummary
|
||||
)
|
||||
|
||||
|
||||
class TestCostService:
|
||||
"""成本服务测试类"""
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_material_info(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_material: Material
|
||||
):
|
||||
"""测试获取耗材信息"""
|
||||
service = CostService(db_session)
|
||||
|
||||
# 获取存在的耗材
|
||||
material = await service.get_material_info(sample_material.id)
|
||||
assert material is not None
|
||||
assert material.material_name == "冷凝胶"
|
||||
assert material.unit_price == Decimal("2.00")
|
||||
|
||||
# 获取不存在的耗材
|
||||
material = await service.get_material_info(99999)
|
||||
assert material is None
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_equipment_info(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_equipment: Equipment
|
||||
):
|
||||
"""测试获取设备信息"""
|
||||
service = CostService(db_session)
|
||||
|
||||
# 获取存在的设备
|
||||
equipment = await service.get_equipment_info(sample_equipment.id)
|
||||
assert equipment is not None
|
||||
assert equipment.equipment_name == "光子仪"
|
||||
assert equipment.depreciation_per_use == Decimal("47.50")
|
||||
|
||||
# 获取不存在的设备
|
||||
equipment = await service.get_equipment_info(99999)
|
||||
assert equipment is None
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_staff_level_info(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_staff_level: StaffLevel
|
||||
):
|
||||
"""测试获取人员级别信息"""
|
||||
service = CostService(db_session)
|
||||
|
||||
# 获取存在的级别
|
||||
level = await service.get_staff_level_info(sample_staff_level.id)
|
||||
assert level is not None
|
||||
assert level.level_name == "中级美容师"
|
||||
assert level.hourly_rate == Decimal("50.00")
|
||||
|
||||
# 获取不存在的级别
|
||||
level = await service.get_staff_level_info(99999)
|
||||
assert level is None
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_material_cost(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project_with_costs: Project
|
||||
):
|
||||
"""测试耗材成本计算"""
|
||||
service = CostService(db_session)
|
||||
|
||||
total, breakdown = await service.calculate_material_cost(
|
||||
sample_project_with_costs.id
|
||||
)
|
||||
|
||||
assert total == Decimal("40.00") # 20 * 2.00
|
||||
assert len(breakdown) == 1
|
||||
assert breakdown[0]["name"] == "冷凝胶"
|
||||
assert breakdown[0]["quantity"] == 20.0
|
||||
assert breakdown[0]["total"] == 40.0
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_equipment_cost(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project_with_costs: Project
|
||||
):
|
||||
"""测试设备折旧成本计算"""
|
||||
service = CostService(db_session)
|
||||
|
||||
total, breakdown = await service.calculate_equipment_cost(
|
||||
sample_project_with_costs.id
|
||||
)
|
||||
|
||||
assert total == Decimal("47.50") # 1 * 47.50
|
||||
assert len(breakdown) == 1
|
||||
assert breakdown[0]["name"] == "光子仪"
|
||||
assert breakdown[0]["depreciation_per_use"] == 47.5
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_labor_cost(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project_with_costs: Project
|
||||
):
|
||||
"""测试人工成本计算"""
|
||||
service = CostService(db_session)
|
||||
|
||||
total, breakdown = await service.calculate_labor_cost(
|
||||
sample_project_with_costs.id
|
||||
)
|
||||
|
||||
assert total == Decimal("50.00") # 60分钟 / 60 * 50
|
||||
assert len(breakdown) == 1
|
||||
assert breakdown[0]["name"] == "中级美容师"
|
||||
assert breakdown[0]["duration_minutes"] == 60
|
||||
assert breakdown[0]["hourly_rate"] == 50.0
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_fixed_cost_allocation_by_count(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project: Project,
|
||||
sample_fixed_cost: FixedCost
|
||||
):
|
||||
"""测试固定成本按项目数量分摊"""
|
||||
service = CostService(db_session)
|
||||
|
||||
allocation, detail = await service.calculate_fixed_cost_allocation(
|
||||
sample_project.id,
|
||||
method=AllocationMethod.COUNT
|
||||
)
|
||||
|
||||
# 只有一个项目,分摊全部固定成本
|
||||
assert allocation == Decimal("30000.00")
|
||||
assert detail["method"] == "count"
|
||||
assert detail["project_count"] == 1
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_fixed_cost_allocation_by_duration(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project: Project,
|
||||
sample_fixed_cost: FixedCost
|
||||
):
|
||||
"""测试固定成本按时长分摊"""
|
||||
service = CostService(db_session)
|
||||
|
||||
allocation, detail = await service.calculate_fixed_cost_allocation(
|
||||
sample_project.id,
|
||||
method=AllocationMethod.DURATION
|
||||
)
|
||||
|
||||
# 只有一个项目,占比 100%
|
||||
assert allocation == Decimal("30000.00")
|
||||
assert detail["method"] == "duration"
|
||||
assert detail["project_duration"] == 60
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_project_cost(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project_with_costs: Project,
|
||||
sample_fixed_cost: FixedCost
|
||||
):
|
||||
"""测试项目总成本计算"""
|
||||
service = CostService(db_session)
|
||||
|
||||
result = await service.calculate_project_cost(
|
||||
sample_project_with_costs.id,
|
||||
allocation_method=AllocationMethod.COUNT
|
||||
)
|
||||
|
||||
assert result.project_id == sample_project_with_costs.id
|
||||
assert result.project_name == "光子嫩肤"
|
||||
|
||||
# 验证成本构成
|
||||
breakdown = result.cost_breakdown
|
||||
assert breakdown["material_cost"]["subtotal"] == 40.0
|
||||
assert breakdown["equipment_cost"]["subtotal"] == 47.5
|
||||
assert breakdown["labor_cost"]["subtotal"] == 50.0
|
||||
|
||||
# 总成本 = 耗材40 + 设备47.5 + 人工50 + 固定30000
|
||||
expected_total = 40 + 47.5 + 50 + 30000
|
||||
assert result.total_cost == expected_total
|
||||
assert result.min_price_suggestion == expected_total
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_project_cost_not_found(
|
||||
self,
|
||||
db_session: AsyncSession
|
||||
):
|
||||
"""测试项目不存在时的错误处理"""
|
||||
service = CostService(db_session)
|
||||
|
||||
with pytest.raises(ValueError, match="项目不存在"):
|
||||
await service.calculate_project_cost(99999)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_cost_item_material(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project: Project,
|
||||
sample_material: Material
|
||||
):
|
||||
"""测试添加耗材成本明细"""
|
||||
service = CostService(db_session)
|
||||
|
||||
cost_item = await service.add_cost_item(
|
||||
project_id=sample_project.id,
|
||||
item_type=CostItemType.MATERIAL,
|
||||
item_id=sample_material.id,
|
||||
quantity=10,
|
||||
remark="测试备注"
|
||||
)
|
||||
|
||||
assert cost_item.project_id == sample_project.id
|
||||
assert cost_item.item_type == "material"
|
||||
assert cost_item.quantity == Decimal("10")
|
||||
assert cost_item.unit_cost == Decimal("2.00")
|
||||
assert cost_item.total_cost == Decimal("20.00")
|
||||
assert cost_item.remark == "测试备注"
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_cost_item_equipment(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project: Project,
|
||||
sample_equipment: Equipment
|
||||
):
|
||||
"""测试添加设备折旧成本明细"""
|
||||
service = CostService(db_session)
|
||||
|
||||
cost_item = await service.add_cost_item(
|
||||
project_id=sample_project.id,
|
||||
item_type=CostItemType.EQUIPMENT,
|
||||
item_id=sample_equipment.id,
|
||||
quantity=1,
|
||||
)
|
||||
|
||||
assert cost_item.item_type == "equipment"
|
||||
assert cost_item.unit_cost == Decimal("47.50")
|
||||
assert cost_item.total_cost == Decimal("47.50")
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_cost_item_not_found(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project: Project
|
||||
):
|
||||
"""测试添加不存在的耗材/设备时的错误处理"""
|
||||
service = CostService(db_session)
|
||||
|
||||
with pytest.raises(ValueError, match="耗材不存在"):
|
||||
await service.add_cost_item(
|
||||
project_id=sample_project.id,
|
||||
item_type=CostItemType.MATERIAL,
|
||||
item_id=99999,
|
||||
quantity=1,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="设备不存在"):
|
||||
await service.add_cost_item(
|
||||
project_id=sample_project.id,
|
||||
item_type=CostItemType.EQUIPMENT,
|
||||
item_id=99999,
|
||||
quantity=1,
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_labor_cost(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project: Project,
|
||||
sample_staff_level: StaffLevel
|
||||
):
|
||||
"""测试添加人工成本"""
|
||||
service = CostService(db_session)
|
||||
|
||||
labor_cost = await service.add_labor_cost(
|
||||
project_id=sample_project.id,
|
||||
staff_level_id=sample_staff_level.id,
|
||||
duration_minutes=30,
|
||||
remark="测试人工"
|
||||
)
|
||||
|
||||
assert labor_cost.project_id == sample_project.id
|
||||
assert labor_cost.duration_minutes == 30
|
||||
assert labor_cost.hourly_rate == Decimal("50.00")
|
||||
# 30分钟 / 60 * 50 = 25
|
||||
assert labor_cost.labor_cost == Decimal("25.00")
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_labor_cost_not_found(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project: Project
|
||||
):
|
||||
"""测试添加不存在的人员级别时的错误处理"""
|
||||
service = CostService(db_session)
|
||||
|
||||
with pytest.raises(ValueError, match="人员级别不存在"):
|
||||
await service.add_labor_cost(
|
||||
project_id=sample_project.id,
|
||||
staff_level_id=99999,
|
||||
duration_minutes=30,
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_cost_item(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project: Project,
|
||||
sample_material: Material
|
||||
):
|
||||
"""测试更新成本明细"""
|
||||
service = CostService(db_session)
|
||||
|
||||
# 先添加
|
||||
cost_item = await service.add_cost_item(
|
||||
project_id=sample_project.id,
|
||||
item_type=CostItemType.MATERIAL,
|
||||
item_id=sample_material.id,
|
||||
quantity=10,
|
||||
)
|
||||
|
||||
# 更新数量
|
||||
updated = await service.update_cost_item(
|
||||
cost_item=cost_item,
|
||||
quantity=20,
|
||||
remark="更新后备注"
|
||||
)
|
||||
|
||||
assert updated.quantity == Decimal("20")
|
||||
assert updated.total_cost == Decimal("40.00") # 20 * 2
|
||||
assert updated.remark == "更新后备注"
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_labor_cost(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project: Project,
|
||||
sample_staff_level: StaffLevel
|
||||
):
|
||||
"""测试更新人工成本"""
|
||||
service = CostService(db_session)
|
||||
|
||||
# 先添加
|
||||
labor = await service.add_labor_cost(
|
||||
project_id=sample_project.id,
|
||||
staff_level_id=sample_staff_level.id,
|
||||
duration_minutes=30,
|
||||
)
|
||||
|
||||
# 更新时长
|
||||
updated = await service.update_labor_cost(
|
||||
labor_item=labor,
|
||||
duration_minutes=60,
|
||||
)
|
||||
|
||||
assert updated.duration_minutes == 60
|
||||
assert updated.labor_cost == Decimal("50.00") # 60/60 * 50
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_project_cost(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project: Project
|
||||
):
|
||||
"""测试没有成本明细的项目计算"""
|
||||
service = CostService(db_session)
|
||||
|
||||
# 计算空项目成本(无固定成本)
|
||||
total_material, _ = await service.calculate_material_cost(sample_project.id)
|
||||
total_equipment, _ = await service.calculate_equipment_cost(sample_project.id)
|
||||
total_labor, _ = await service.calculate_labor_cost(sample_project.id)
|
||||
|
||||
assert total_material == Decimal("0")
|
||||
assert total_equipment == Decimal("0")
|
||||
assert total_labor == Decimal("0")
|
||||
305
后端服务/tests/test_services/test_market_service.py
Normal file
305
后端服务/tests/test_services/test_market_service.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""市场分析服务单元测试
|
||||
|
||||
测试 MarketService 的核心业务逻辑
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.market_service import MarketService
|
||||
from app.models import (
|
||||
Project, Competitor, CompetitorPrice, BenchmarkPrice, Category
|
||||
)
|
||||
|
||||
|
||||
class TestMarketService:
|
||||
"""市场分析服务测试类"""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_price_statistics_empty(self):
|
||||
"""测试空价格列表的统计"""
|
||||
service = MarketService(None) # 不需要 db
|
||||
|
||||
stats = service.calculate_price_statistics([])
|
||||
|
||||
assert stats.min_price == 0
|
||||
assert stats.max_price == 0
|
||||
assert stats.avg_price == 0
|
||||
assert stats.median_price == 0
|
||||
assert stats.std_deviation is None or stats.std_deviation == 0
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_price_statistics_single(self):
|
||||
"""测试单个价格的统计"""
|
||||
service = MarketService(None)
|
||||
|
||||
stats = service.calculate_price_statistics([500.0])
|
||||
|
||||
assert stats.min_price == 500.0
|
||||
assert stats.max_price == 500.0
|
||||
assert stats.avg_price == 500.0
|
||||
assert stats.median_price == 500.0
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_price_statistics_multiple(self):
|
||||
"""测试多个价格的统计"""
|
||||
service = MarketService(None)
|
||||
|
||||
prices = [300.0, 400.0, 500.0, 600.0, 700.0]
|
||||
stats = service.calculate_price_statistics(prices)
|
||||
|
||||
assert stats.min_price == 300.0
|
||||
assert stats.max_price == 700.0
|
||||
assert stats.avg_price == 500.0 # (300+400+500+600+700)/5
|
||||
assert stats.median_price == 500.0 # 中位数
|
||||
assert stats.std_deviation is not None
|
||||
assert stats.std_deviation > 0
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_price_distribution(self):
|
||||
"""测试价格分布计算"""
|
||||
service = MarketService(None)
|
||||
|
||||
# 价格范围 300-900,分为三个区间
|
||||
# 低: 300-500, 中: 500-700, 高: 700-900
|
||||
prices = [350.0, 450.0, 550.0, 650.0, 750.0, 850.0]
|
||||
|
||||
distribution = service.calculate_price_distribution(
|
||||
prices=prices,
|
||||
min_price=300.0,
|
||||
max_price=900.0
|
||||
)
|
||||
|
||||
# 验证分布
|
||||
assert distribution.low.count == 2 # 350, 450
|
||||
assert distribution.medium.count == 2 # 550, 650
|
||||
assert distribution.high.count == 2 # 750, 850
|
||||
|
||||
# 验证百分比
|
||||
assert distribution.low.percentage == pytest.approx(33.3, rel=0.1)
|
||||
assert distribution.medium.percentage == pytest.approx(33.3, rel=0.1)
|
||||
assert distribution.high.percentage == pytest.approx(33.3, rel=0.1)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_price_distribution_empty(self):
|
||||
"""测试空价格列表的分布"""
|
||||
service = MarketService(None)
|
||||
|
||||
distribution = service.calculate_price_distribution(
|
||||
prices=[],
|
||||
min_price=0,
|
||||
max_price=0
|
||||
)
|
||||
|
||||
assert distribution.low.count == 0
|
||||
assert distribution.medium.count == 0
|
||||
assert distribution.high.count == 0
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_suggested_range(self):
|
||||
"""测试建议定价区间计算"""
|
||||
service = MarketService(None)
|
||||
|
||||
suggested = service.calculate_suggested_range(
|
||||
avg_price=500.0,
|
||||
min_price=300.0,
|
||||
max_price=700.0,
|
||||
benchmark_avg=None
|
||||
)
|
||||
|
||||
# 以均价为中心 ±20%
|
||||
assert suggested.min == pytest.approx(400.0, rel=0.01) # 500 * 0.8
|
||||
assert suggested.max == pytest.approx(600.0, rel=0.01) # 500 * 1.2
|
||||
assert suggested.recommended == 500.0
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_suggested_range_with_benchmark(self):
|
||||
"""测试带标杆参考的建议定价区间"""
|
||||
service = MarketService(None)
|
||||
|
||||
suggested = service.calculate_suggested_range(
|
||||
avg_price=500.0,
|
||||
min_price=300.0,
|
||||
max_price=700.0,
|
||||
benchmark_avg=600.0
|
||||
)
|
||||
|
||||
# 推荐价格 = 市场均价 * 0.6 + 标杆均价 * 0.4
|
||||
expected_recommended = 500 * 0.6 + 600 * 0.4 # 540
|
||||
assert suggested.recommended == pytest.approx(expected_recommended, rel=0.01)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_suggested_range_zero_avg(self):
|
||||
"""测试均价为0时的处理"""
|
||||
service = MarketService(None)
|
||||
|
||||
suggested = service.calculate_suggested_range(
|
||||
avg_price=0,
|
||||
min_price=0,
|
||||
max_price=0,
|
||||
benchmark_avg=None
|
||||
)
|
||||
|
||||
assert suggested.min == 0
|
||||
assert suggested.max == 0
|
||||
assert suggested.recommended == 0
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_competitor_prices_for_project(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_competitor_price: CompetitorPrice,
|
||||
sample_project: Project
|
||||
):
|
||||
"""测试获取项目的竞品价格"""
|
||||
service = MarketService(db_session)
|
||||
|
||||
prices = await service.get_competitor_prices_for_project(
|
||||
sample_project.id
|
||||
)
|
||||
|
||||
assert len(prices) == 1
|
||||
assert float(prices[0].original_price) == 680.0
|
||||
assert float(prices[0].promo_price) == 480.0
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_competitor_prices_filter_by_competitor(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_competitor_price: CompetitorPrice,
|
||||
sample_project: Project,
|
||||
sample_competitor: Competitor
|
||||
):
|
||||
"""测试按竞品机构筛选价格"""
|
||||
service = MarketService(db_session)
|
||||
|
||||
# 指定竞品ID
|
||||
prices = await service.get_competitor_prices_for_project(
|
||||
sample_project.id,
|
||||
competitor_ids=[sample_competitor.id]
|
||||
)
|
||||
|
||||
assert len(prices) == 1
|
||||
|
||||
# 指定不存在的竞品ID
|
||||
prices = await service.get_competitor_prices_for_project(
|
||||
sample_project.id,
|
||||
competitor_ids=[99999]
|
||||
)
|
||||
|
||||
assert len(prices) == 0
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_market(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project: Project,
|
||||
sample_competitor_price: CompetitorPrice
|
||||
):
|
||||
"""测试市场分析"""
|
||||
service = MarketService(db_session)
|
||||
|
||||
result = await service.analyze_market(
|
||||
project_id=sample_project.id,
|
||||
include_benchmark=False
|
||||
)
|
||||
|
||||
assert result.project_id == sample_project.id
|
||||
assert result.project_name == "光子嫩肤"
|
||||
assert result.competitor_count == 1
|
||||
assert result.price_statistics.min_price == 680.0
|
||||
assert result.price_statistics.max_price == 680.0
|
||||
assert result.price_statistics.avg_price == 680.0
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_market_not_found(
|
||||
self,
|
||||
db_session: AsyncSession
|
||||
):
|
||||
"""测试项目不存在时的错误处理"""
|
||||
service = MarketService(db_session)
|
||||
|
||||
with pytest.raises(ValueError, match="项目不存在"):
|
||||
await service.analyze_market(project_id=99999)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_latest_analysis(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project: Project,
|
||||
sample_competitor_price: CompetitorPrice
|
||||
):
|
||||
"""测试获取最新分析结果"""
|
||||
service = MarketService(db_session)
|
||||
|
||||
# 先执行分析
|
||||
await service.analyze_market(
|
||||
project_id=sample_project.id,
|
||||
include_benchmark=False
|
||||
)
|
||||
|
||||
# 获取最新结果
|
||||
latest = await service.get_latest_analysis(sample_project.id)
|
||||
|
||||
assert latest is not None
|
||||
assert latest.project_id == sample_project.id
|
||||
assert float(latest.market_avg_price) == 680.0
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_benchmark_prices_empty(
|
||||
self,
|
||||
db_session: AsyncSession
|
||||
):
|
||||
"""测试没有标杆价格时的处理"""
|
||||
service = MarketService(db_session)
|
||||
|
||||
benchmarks = await service.get_benchmark_prices_for_category(None)
|
||||
assert benchmarks == []
|
||||
|
||||
benchmarks = await service.get_benchmark_prices_for_category(99999)
|
||||
assert benchmarks == []
|
||||
|
||||
|
||||
class TestMarketServiceEdgeCases:
|
||||
"""市场分析服务边界情况测试"""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_price_distribution_same_min_max(self):
|
||||
"""测试最小最大价相同时的分布"""
|
||||
service = MarketService(None)
|
||||
|
||||
distribution = service.calculate_price_distribution(
|
||||
prices=[500.0, 500.0],
|
||||
min_price=500.0,
|
||||
max_price=500.0
|
||||
)
|
||||
|
||||
# 应返回 N/A
|
||||
assert distribution.low.range == "N/A"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_statistics_with_outliers(self):
|
||||
"""测试包含极端值的统计"""
|
||||
service = MarketService(None)
|
||||
|
||||
# 包含一个极端高价
|
||||
prices = [300.0, 400.0, 500.0, 600.0, 5000.0]
|
||||
stats = service.calculate_price_statistics(prices)
|
||||
|
||||
assert stats.min_price == 300.0
|
||||
assert stats.max_price == 5000.0
|
||||
# 均值会被拉高
|
||||
assert stats.avg_price == 1360.0 # (300+400+500+600+5000)/5
|
||||
# 中位数不受极端值影响
|
||||
assert stats.median_price == 500.0
|
||||
# 标准差会很大
|
||||
assert stats.std_deviation > 1000
|
||||
369
后端服务/tests/test_services/test_pricing_service.py
Normal file
369
后端服务/tests/test_services/test_pricing_service.py
Normal file
@@ -0,0 +1,369 @@
|
||||
"""智能定价服务单元测试
|
||||
|
||||
测试 PricingService 的核心业务逻辑
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.pricing_service import PricingService
|
||||
from app.schemas.pricing import (
|
||||
StrategyType, MarketReference, StrategySuggestion, PricingSuggestions
|
||||
)
|
||||
from app.models import Project, ProjectCostSummary, PricingPlan
|
||||
|
||||
|
||||
class TestPricingService:
|
||||
"""智能定价服务测试类"""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_strategy_price_traffic(self):
|
||||
"""测试引流款定价策略"""
|
||||
service = PricingService(None)
|
||||
|
||||
suggestion = service.calculate_strategy_price(
|
||||
base_cost=100.0,
|
||||
strategy=StrategyType.TRAFFIC,
|
||||
)
|
||||
|
||||
# 引流款利润率 10%-20%,使用中间值 15%
|
||||
# 价格 = 100 / (1 - 0.15) ≈ 117.65
|
||||
assert suggestion.strategy == "引流款"
|
||||
assert suggestion.suggested_price > 100 # 大于成本
|
||||
assert suggestion.suggested_price < 130 # 利润率适中
|
||||
assert suggestion.margin > 0
|
||||
assert "引流" in suggestion.description
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_strategy_price_profit(self):
|
||||
"""测试利润款定价策略"""
|
||||
service = PricingService(None)
|
||||
|
||||
suggestion = service.calculate_strategy_price(
|
||||
base_cost=100.0,
|
||||
strategy=StrategyType.PROFIT,
|
||||
target_margin=50, # 50% 目标毛利率
|
||||
)
|
||||
|
||||
# 价格 = 100 / (1 - 0.5) = 200
|
||||
assert suggestion.strategy == "利润款"
|
||||
assert suggestion.suggested_price >= 200
|
||||
assert suggestion.margin >= 45 # 接近目标
|
||||
assert "日常" in suggestion.description
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_strategy_price_premium(self):
|
||||
"""测试高端款定价策略"""
|
||||
service = PricingService(None)
|
||||
|
||||
suggestion = service.calculate_strategy_price(
|
||||
base_cost=100.0,
|
||||
strategy=StrategyType.PREMIUM,
|
||||
)
|
||||
|
||||
# 高端款利润率 60%-80%,使用中间值 70%
|
||||
# 价格 = 100 / (1 - 0.7) ≈ 333
|
||||
assert suggestion.strategy == "高端款"
|
||||
assert suggestion.suggested_price > 300
|
||||
assert suggestion.margin > 60
|
||||
assert "高端" in suggestion.description
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_strategy_price_with_market_reference(self):
|
||||
"""测试带市场参考的定价"""
|
||||
service = PricingService(None)
|
||||
|
||||
market_ref = MarketReference(min=80.0, max=150.0, avg=100.0)
|
||||
|
||||
# 引流款应该参考市场最低价
|
||||
suggestion = service.calculate_strategy_price(
|
||||
base_cost=50.0,
|
||||
strategy=StrategyType.TRAFFIC,
|
||||
market_ref=market_ref,
|
||||
)
|
||||
|
||||
# 应该取市场最低价的 90% 和成本定价的较低者
|
||||
assert suggestion.suggested_price <= 100 # 不会太高
|
||||
assert suggestion.suggested_price >= 50 * 1.05 # 不低于成本
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_strategy_price_ensures_profit(self):
|
||||
"""测试确保价格不低于成本"""
|
||||
service = PricingService(None)
|
||||
|
||||
market_ref = MarketReference(min=30.0, max=50.0, avg=40.0)
|
||||
|
||||
# 即使市场价很低,也不能低于成本
|
||||
suggestion = service.calculate_strategy_price(
|
||||
base_cost=100.0, # 成本高于市场价
|
||||
strategy=StrategyType.TRAFFIC,
|
||||
market_ref=market_ref,
|
||||
)
|
||||
|
||||
# 价格至少是成本的 1.05 倍
|
||||
assert suggestion.suggested_price >= 100 * 1.05
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_all_strategies(self):
|
||||
"""测试计算所有策略"""
|
||||
service = PricingService(None)
|
||||
|
||||
suggestions = service.calculate_all_strategies(
|
||||
base_cost=100.0,
|
||||
target_margin=50.0,
|
||||
)
|
||||
|
||||
assert suggestions.traffic is not None
|
||||
assert suggestions.profit is not None
|
||||
assert suggestions.premium is not None
|
||||
|
||||
# 价格应该递增:引流款 < 利润款 < 高端款
|
||||
assert suggestions.traffic.suggested_price < suggestions.profit.suggested_price
|
||||
assert suggestions.profit.suggested_price < suggestions.premium.suggested_price
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_all_strategies_selected(self):
|
||||
"""测试只计算选定的策略"""
|
||||
service = PricingService(None)
|
||||
|
||||
suggestions = service.calculate_all_strategies(
|
||||
base_cost=100.0,
|
||||
target_margin=50.0,
|
||||
strategies=[StrategyType.TRAFFIC, StrategyType.PROFIT],
|
||||
)
|
||||
|
||||
assert suggestions.traffic is not None
|
||||
assert suggestions.profit is not None
|
||||
assert suggestions.premium is None
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_with_cost(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project_with_costs: Project
|
||||
):
|
||||
"""测试获取项目及成本"""
|
||||
service = PricingService(db_session)
|
||||
|
||||
project, cost_summary = await service.get_project_with_cost(
|
||||
sample_project_with_costs.id
|
||||
)
|
||||
|
||||
assert project.id == sample_project_with_costs.id
|
||||
assert project.project_name == "光子嫩肤"
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_with_cost_not_found(
|
||||
self,
|
||||
db_session: AsyncSession
|
||||
):
|
||||
"""测试项目不存在时的错误处理"""
|
||||
service = PricingService(db_session)
|
||||
|
||||
with pytest.raises(ValueError, match="项目不存在"):
|
||||
await service.get_project_with_cost(99999)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pricing_plan(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project: Project
|
||||
):
|
||||
"""测试创建定价方案"""
|
||||
# 先添加成本汇总
|
||||
cost_summary = ProjectCostSummary(
|
||||
project_id=sample_project.id,
|
||||
material_cost=Decimal("40.00"),
|
||||
equipment_cost=Decimal("50.00"),
|
||||
labor_cost=Decimal("60.00"),
|
||||
fixed_cost_allocation=Decimal("30.00"),
|
||||
total_cost=Decimal("180.00"),
|
||||
calculated_at=datetime.now(),
|
||||
)
|
||||
db_session.add(cost_summary)
|
||||
await db_session.commit()
|
||||
|
||||
service = PricingService(db_session)
|
||||
|
||||
plan = await service.create_pricing_plan(
|
||||
project_id=sample_project.id,
|
||||
plan_name="测试定价方案",
|
||||
strategy_type=StrategyType.PROFIT,
|
||||
target_margin=50.0,
|
||||
)
|
||||
|
||||
assert plan.project_id == sample_project.id
|
||||
assert plan.plan_name == "测试定价方案"
|
||||
assert plan.strategy_type == "profit"
|
||||
assert float(plan.target_margin) == 50.0
|
||||
assert float(plan.base_cost) == 180.0
|
||||
assert plan.suggested_price > plan.base_cost
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pricing_plan(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_pricing_plan: PricingPlan
|
||||
):
|
||||
"""测试更新定价方案"""
|
||||
service = PricingService(db_session)
|
||||
|
||||
updated = await service.update_pricing_plan(
|
||||
plan_id=sample_pricing_plan.id,
|
||||
final_price=599.00,
|
||||
plan_name="更新后方案名",
|
||||
)
|
||||
|
||||
assert float(updated.final_price) == 599.00
|
||||
assert updated.plan_name == "更新后方案名"
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pricing_plan_not_found(
|
||||
self,
|
||||
db_session: AsyncSession
|
||||
):
|
||||
"""测试更新不存在的方案"""
|
||||
service = PricingService(db_session)
|
||||
|
||||
with pytest.raises(ValueError, match="定价方案不存在"):
|
||||
await service.update_pricing_plan(
|
||||
plan_id=99999,
|
||||
final_price=599.00,
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_simulate_strategies(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_project: Project
|
||||
):
|
||||
"""测试策略模拟"""
|
||||
# 添加成本汇总
|
||||
cost_summary = ProjectCostSummary(
|
||||
project_id=sample_project.id,
|
||||
total_cost=Decimal("200.00"),
|
||||
material_cost=Decimal("100.00"),
|
||||
equipment_cost=Decimal("50.00"),
|
||||
labor_cost=Decimal("50.00"),
|
||||
fixed_cost_allocation=Decimal("0.00"),
|
||||
calculated_at=datetime.now(),
|
||||
)
|
||||
db_session.add(cost_summary)
|
||||
await db_session.commit()
|
||||
|
||||
service = PricingService(db_session)
|
||||
|
||||
response = await service.simulate_strategies(
|
||||
project_id=sample_project.id,
|
||||
strategies=[StrategyType.TRAFFIC, StrategyType.PROFIT, StrategyType.PREMIUM],
|
||||
target_margin=50.0,
|
||||
)
|
||||
|
||||
assert response.project_id == sample_project.id
|
||||
assert response.base_cost == 200.0
|
||||
assert len(response.results) == 3
|
||||
|
||||
# 验证结果排序
|
||||
prices = [r.suggested_price for r in response.results]
|
||||
assert prices == sorted(prices) # 应该是升序
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_format_cost_data(self):
|
||||
"""测试成本数据格式化"""
|
||||
service = PricingService(None)
|
||||
|
||||
# 测试空数据
|
||||
result = service._format_cost_data(None)
|
||||
assert "暂无成本数据" in result
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_format_market_data(self):
|
||||
"""测试市场数据格式化"""
|
||||
service = PricingService(None)
|
||||
|
||||
# 测试空数据
|
||||
result = service._format_market_data(None)
|
||||
assert "暂无市场行情数据" in result
|
||||
|
||||
# 测试有数据
|
||||
market_ref = MarketReference(min=100.0, max=500.0, avg=300.0)
|
||||
result = service._format_market_data(market_ref)
|
||||
assert "100.00" in result
|
||||
assert "500.00" in result
|
||||
assert "300.00" in result
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_extract_recommendations(self):
|
||||
"""测试提取 AI 建议列表"""
|
||||
service = PricingService(None)
|
||||
|
||||
content = """
|
||||
根据分析,建议如下:
|
||||
- 建议一:常规定价 580 元
|
||||
- 建议二:新客首单 388 元
|
||||
* 建议三:VIP 会员 520 元
|
||||
1. 定期促销活动
|
||||
2. 会员体系建设
|
||||
"""
|
||||
|
||||
recommendations = service._extract_recommendations(content)
|
||||
|
||||
assert len(recommendations) == 5
|
||||
assert "常规定价" in recommendations[0]
|
||||
|
||||
|
||||
class TestPricingServiceWithAI:
|
||||
"""需要 AI 服务的定价测试"""
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
@patch('app.services.pricing_service.AIServiceWrapper')
|
||||
async def test_generate_pricing_advice_ai_failure(
|
||||
self,
|
||||
mock_ai_wrapper,
|
||||
db_session: AsyncSession,
|
||||
sample_project: Project
|
||||
):
|
||||
"""测试 AI 调用失败时的降级处理"""
|
||||
# 添加成本汇总
|
||||
cost_summary = ProjectCostSummary(
|
||||
project_id=sample_project.id,
|
||||
total_cost=Decimal("200.00"),
|
||||
material_cost=Decimal("100.00"),
|
||||
equipment_cost=Decimal("50.00"),
|
||||
labor_cost=Decimal("50.00"),
|
||||
fixed_cost_allocation=Decimal("0.00"),
|
||||
calculated_at=datetime.now(),
|
||||
)
|
||||
db_session.add(cost_summary)
|
||||
await db_session.commit()
|
||||
|
||||
# 模拟 AI 调用失败
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.chat = AsyncMock(side_effect=Exception("AI 服务不可用"))
|
||||
mock_ai_wrapper.return_value = mock_instance
|
||||
|
||||
service = PricingService(db_session)
|
||||
|
||||
# 即使 AI 失败,基本定价计算应该仍然返回
|
||||
response = await service.generate_pricing_advice(
|
||||
project_id=sample_project.id,
|
||||
target_margin=50.0,
|
||||
)
|
||||
|
||||
# 验证基本定价仍然可用
|
||||
assert response.project_id == sample_project.id
|
||||
assert response.cost_base == 200.0
|
||||
assert response.pricing_suggestions is not None
|
||||
# AI 建议可能为空
|
||||
assert response.ai_advice is None or response.ai_usage is None
|
||||
211
后端服务/tests/test_services/test_profit_service.py
Normal file
211
后端服务/tests/test_services/test_profit_service.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""利润模拟服务单元测试
|
||||
|
||||
测试 ProfitService 的核心业务逻辑
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from decimal import Decimal
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.profit_service import ProfitService
|
||||
from app.schemas.profit import PeriodType
|
||||
from app.models import PricingPlan, FixedCost
|
||||
|
||||
|
||||
class TestProfitService:
|
||||
"""利润模拟服务测试类"""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_profit_basic(self):
|
||||
"""测试基础利润计算"""
|
||||
service = ProfitService(None)
|
||||
|
||||
revenue, cost, profit, margin = service.calculate_profit(
|
||||
price=100.0,
|
||||
cost_per_unit=60.0,
|
||||
volume=100
|
||||
)
|
||||
|
||||
assert revenue == 10000.0
|
||||
assert cost == 6000.0
|
||||
assert profit == 4000.0
|
||||
assert margin == 40.0
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_profit_zero_revenue(self):
|
||||
"""测试零收入时的处理"""
|
||||
service = ProfitService(None)
|
||||
|
||||
revenue, cost, profit, margin = service.calculate_profit(
|
||||
price=100.0,
|
||||
cost_per_unit=60.0,
|
||||
volume=0
|
||||
)
|
||||
|
||||
assert revenue == 0
|
||||
assert cost == 0
|
||||
assert profit == 0
|
||||
assert margin == 0
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_profit_negative(self):
|
||||
"""测试亏损情况"""
|
||||
service = ProfitService(None)
|
||||
|
||||
revenue, cost, profit, margin = service.calculate_profit(
|
||||
price=50.0,
|
||||
cost_per_unit=60.0,
|
||||
volume=100
|
||||
)
|
||||
|
||||
assert revenue == 5000.0
|
||||
assert cost == 6000.0
|
||||
assert profit == -1000.0
|
||||
assert margin == -20.0
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_breakeven_basic(self):
|
||||
"""测试基础盈亏平衡计算"""
|
||||
service = ProfitService(None)
|
||||
|
||||
breakeven = service.calculate_breakeven(
|
||||
price=100.0,
|
||||
variable_cost=60.0,
|
||||
fixed_cost=0
|
||||
)
|
||||
|
||||
assert breakeven == 1
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_breakeven_with_fixed_cost(self):
|
||||
"""测试有固定成本的盈亏平衡"""
|
||||
service = ProfitService(None)
|
||||
|
||||
breakeven = service.calculate_breakeven(
|
||||
price=100.0,
|
||||
variable_cost=60.0,
|
||||
fixed_cost=4000.0
|
||||
)
|
||||
|
||||
assert breakeven == 101
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_calculate_breakeven_no_margin(self):
|
||||
"""测试边际贡献为负时的处理"""
|
||||
service = ProfitService(None)
|
||||
|
||||
breakeven = service.calculate_breakeven(
|
||||
price=50.0,
|
||||
variable_cost=60.0,
|
||||
fixed_cost=1000.0
|
||||
)
|
||||
|
||||
assert breakeven == 999999
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pricing_plan(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_pricing_plan: PricingPlan
|
||||
):
|
||||
"""测试获取定价方案"""
|
||||
service = ProfitService(db_session)
|
||||
|
||||
plan = await service.get_pricing_plan(sample_pricing_plan.id)
|
||||
|
||||
assert plan.id == sample_pricing_plan.id
|
||||
assert plan.plan_name == "2026年Q1定价"
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pricing_plan_not_found(
|
||||
self,
|
||||
db_session: AsyncSession
|
||||
):
|
||||
"""测试获取不存在的方案"""
|
||||
service = ProfitService(db_session)
|
||||
|
||||
with pytest.raises(ValueError, match="定价方案不存在"):
|
||||
await service.get_pricing_plan(99999)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_monthly_fixed_cost(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_fixed_cost: FixedCost
|
||||
):
|
||||
"""测试获取月度固定成本"""
|
||||
service = ProfitService(db_session)
|
||||
|
||||
total = await service.get_monthly_fixed_cost()
|
||||
|
||||
assert total == Decimal("30000.00")
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_simulate_profit(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_pricing_plan: PricingPlan
|
||||
):
|
||||
"""测试利润模拟"""
|
||||
service = ProfitService(db_session)
|
||||
|
||||
response = await service.simulate_profit(
|
||||
pricing_plan_id=sample_pricing_plan.id,
|
||||
price=580.0,
|
||||
estimated_volume=100,
|
||||
period_type=PeriodType.MONTHLY,
|
||||
)
|
||||
|
||||
assert response.pricing_plan_id == sample_pricing_plan.id
|
||||
assert response.input.price == 580.0
|
||||
assert response.input.estimated_volume == 100
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_sensitivity_analysis(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_pricing_plan: PricingPlan
|
||||
):
|
||||
"""测试敏感性分析"""
|
||||
service = ProfitService(db_session)
|
||||
|
||||
sim_response = await service.simulate_profit(
|
||||
pricing_plan_id=sample_pricing_plan.id,
|
||||
price=580.0,
|
||||
estimated_volume=100,
|
||||
period_type=PeriodType.MONTHLY,
|
||||
)
|
||||
|
||||
response = await service.sensitivity_analysis(
|
||||
simulation_id=sim_response.simulation_id,
|
||||
price_change_rates=[-20, -10, 0, 10, 20]
|
||||
)
|
||||
|
||||
assert response.simulation_id == sim_response.simulation_id
|
||||
assert len(response.sensitivity_results) == 5
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_breakeven_analysis(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_pricing_plan: PricingPlan,
|
||||
sample_fixed_cost: FixedCost
|
||||
):
|
||||
"""测试盈亏平衡分析"""
|
||||
service = ProfitService(db_session)
|
||||
|
||||
response = await service.breakeven_analysis(
|
||||
pricing_plan_id=sample_pricing_plan.id
|
||||
)
|
||||
|
||||
assert response.pricing_plan_id == sample_pricing_plan.id
|
||||
assert response.price > 0
|
||||
assert response.breakeven_volume > 0
|
||||
Reference in New Issue
Block a user