"""成本计算服务单元测试 测试 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")