Files
smart-project-pricing/后端服务/app/routers/projects.py
2026-01-31 21:33:06 +08:00

905 lines
29 KiB
Python

"""服务项目管理路由
实现服务项目的 CRUD 操作和成本管理
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models import (
Project,
ProjectCostItem,
ProjectLaborCost,
ProjectCostSummary,
Category,
Material,
Equipment,
StaffLevel,
)
from app.schemas.common import ResponseModel, PaginatedData, ErrorCode
from app.schemas.project import (
ProjectCreate,
ProjectUpdate,
ProjectResponse,
ProjectListResponse,
CostSummaryBrief,
)
from app.schemas.project_cost import (
CostItemCreate,
CostItemUpdate,
CostItemResponse,
LaborCostCreate,
LaborCostUpdate,
LaborCostResponse,
CalculateCostRequest,
CostCalculationResult,
CostSummaryResponse,
ProjectDetailResponse,
CostItemType,
AllocationMethod,
)
from app.services.cost_service import CostService
router = APIRouter()
# 项目允许的排序字段白名单
PROJECT_SORT_FIELDS = {"created_at", "updated_at", "project_code", "project_name", "duration_minutes"}
# ============ 项目 CRUD ============
@router.get("", response_model=ResponseModel[PaginatedData[ProjectListResponse]])
async def get_projects(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
category_id: Optional[int] = Query(None, description="分类筛选"),
keyword: Optional[str] = Query(None, description="关键词搜索"),
is_active: Optional[bool] = Query(None, description="是否启用筛选"),
sort_by: str = Query("created_at", description="排序字段"),
sort_order: str = Query("desc", description="排序方向"),
db: AsyncSession = Depends(get_db),
):
"""获取服务项目列表"""
query = select(Project).options(
selectinload(Project.category),
selectinload(Project.cost_summary),
)
if keyword:
query = query.where(
or_(
Project.project_code.contains(keyword),
Project.project_name.contains(keyword),
)
)
if category_id:
query = query.where(Project.category_id == category_id)
if is_active is not None:
query = query.where(Project.is_active == is_active)
# 排序 - 使用白名单验证防止注入
if sort_by not in PROJECT_SORT_FIELDS:
sort_by = "created_at"
sort_column = getattr(Project, sort_by, Project.created_at)
if sort_order == "asc":
query = query.order_by(sort_column.asc())
else:
query = query.order_by(sort_column.desc())
# 分页
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
result = await db.execute(query)
projects = result.scalars().all()
# 统计总数
count_query = select(func.count(Project.id))
if keyword:
count_query = count_query.where(
or_(
Project.project_code.contains(keyword),
Project.project_name.contains(keyword),
)
)
if category_id:
count_query = count_query.where(Project.category_id == category_id)
if is_active is not None:
count_query = count_query.where(Project.is_active == is_active)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# 构建响应
items = []
for p in projects:
item = {
"id": p.id,
"project_code": p.project_code,
"project_name": p.project_name,
"category_id": p.category_id,
"category_name": p.category.category_name if p.category else None,
"description": p.description,
"duration_minutes": p.duration_minutes,
"is_active": p.is_active,
"cost_summary": None,
"created_at": p.created_at,
"updated_at": p.updated_at,
}
if p.cost_summary:
item["cost_summary"] = CostSummaryBrief(
total_cost=float(p.cost_summary.total_cost),
material_cost=float(p.cost_summary.material_cost),
equipment_cost=float(p.cost_summary.equipment_cost),
labor_cost=float(p.cost_summary.labor_cost),
fixed_cost_allocation=float(p.cost_summary.fixed_cost_allocation),
)
items.append(ProjectListResponse(**item))
return ResponseModel(
data=PaginatedData(
items=items,
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size,
)
)
@router.get("/{project_id}", response_model=ResponseModel[ProjectDetailResponse])
async def get_project(
project_id: int,
db: AsyncSession = Depends(get_db),
):
"""获取项目详情(含成本明细)"""
result = await db.execute(
select(Project).options(
selectinload(Project.category),
selectinload(Project.cost_items),
selectinload(Project.labor_costs).selectinload(ProjectLaborCost.staff_level),
selectinload(Project.cost_summary),
).where(Project.id == project_id)
)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "项目不存在"}
)
# 构建成本明细响应
cost_items = []
for item in project.cost_items:
item_name = None
unit = None
if item.item_type == CostItemType.MATERIAL.value:
material_result = await db.execute(
select(Material).where(Material.id == item.item_id)
)
material = material_result.scalar_one_or_none()
if material:
item_name = material.material_name
unit = material.unit
else:
equipment_result = await db.execute(
select(Equipment).where(Equipment.id == item.item_id)
)
equipment = equipment_result.scalar_one_or_none()
if equipment:
item_name = equipment.equipment_name
unit = ""
cost_items.append(CostItemResponse(
id=item.id,
item_type=CostItemType(item.item_type),
item_id=item.item_id,
item_name=item_name,
quantity=float(item.quantity),
unit=unit,
unit_cost=float(item.unit_cost),
total_cost=float(item.total_cost),
remark=item.remark,
created_at=item.created_at,
updated_at=item.updated_at,
))
# 构建人工成本响应
labor_costs = []
for item in project.labor_costs:
labor_costs.append(LaborCostResponse(
id=item.id,
staff_level_id=item.staff_level_id,
level_name=item.staff_level.level_name if item.staff_level else None,
duration_minutes=item.duration_minutes,
hourly_rate=float(item.hourly_rate),
labor_cost=float(item.labor_cost),
remark=item.remark,
created_at=item.created_at,
updated_at=item.updated_at,
))
# 构建成本汇总
cost_summary = None
if project.cost_summary:
cost_summary = CostSummaryResponse(
project_id=project.id,
material_cost=float(project.cost_summary.material_cost),
equipment_cost=float(project.cost_summary.equipment_cost),
labor_cost=float(project.cost_summary.labor_cost),
fixed_cost_allocation=float(project.cost_summary.fixed_cost_allocation),
total_cost=float(project.cost_summary.total_cost),
calculated_at=project.cost_summary.calculated_at,
)
return ResponseModel(
data=ProjectDetailResponse(
id=project.id,
project_code=project.project_code,
project_name=project.project_name,
category_id=project.category_id,
category_name=project.category.category_name if project.category else None,
description=project.description,
duration_minutes=project.duration_minutes,
is_active=project.is_active,
cost_items=cost_items,
labor_costs=labor_costs,
cost_summary=cost_summary,
created_at=project.created_at,
updated_at=project.updated_at,
)
)
@router.post("", response_model=ResponseModel[ProjectResponse])
async def create_project(
data: ProjectCreate,
db: AsyncSession = Depends(get_db),
):
"""创建服务项目"""
# 检查编码是否已存在
existing = await db.execute(
select(Project).where(Project.project_code == data.project_code)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail={"code": ErrorCode.ALREADY_EXISTS, "message": "项目编码已存在"}
)
# 检查分类是否存在
if data.category_id:
category_result = await db.execute(
select(Category).where(Category.id == data.category_id)
)
if not category_result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail={"code": ErrorCode.NOT_FOUND, "message": "分类不存在"}
)
project = Project(**data.model_dump())
db.add(project)
await db.flush()
await db.refresh(project)
# 获取分类名称
category_name = None
if project.category_id:
result = await db.execute(
select(Category).where(Category.id == project.category_id)
)
category = result.scalar_one_or_none()
if category:
category_name = category.category_name
return ResponseModel(
message="创建成功",
data=ProjectResponse(
id=project.id,
project_code=project.project_code,
project_name=project.project_name,
category_id=project.category_id,
category_name=category_name,
description=project.description,
duration_minutes=project.duration_minutes,
is_active=project.is_active,
cost_summary=None,
created_at=project.created_at,
updated_at=project.updated_at,
)
)
@router.put("/{project_id}", response_model=ResponseModel[ProjectResponse])
async def update_project(
project_id: int,
data: ProjectUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新服务项目"""
result = await db.execute(
select(Project).options(
selectinload(Project.category),
selectinload(Project.cost_summary),
).where(Project.id == project_id)
)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "项目不存在"}
)
# 检查编码是否重复
if data.project_code and data.project_code != project.project_code:
existing = await db.execute(
select(Project).where(Project.project_code == data.project_code)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail={"code": ErrorCode.ALREADY_EXISTS, "message": "项目编码已存在"}
)
# 检查分类是否存在
if data.category_id:
category_result = await db.execute(
select(Category).where(Category.id == data.category_id)
)
if not category_result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail={"code": ErrorCode.NOT_FOUND, "message": "分类不存在"}
)
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(project, field, value)
await db.flush()
await db.refresh(project)
# 获取分类名称
category_name = None
if project.category_id:
cat_result = await db.execute(
select(Category).where(Category.id == project.category_id)
)
category = cat_result.scalar_one_or_none()
if category:
category_name = category.category_name
# 构建成本汇总
cost_summary = None
if project.cost_summary:
cost_summary = CostSummaryBrief(
total_cost=float(project.cost_summary.total_cost),
material_cost=float(project.cost_summary.material_cost),
equipment_cost=float(project.cost_summary.equipment_cost),
labor_cost=float(project.cost_summary.labor_cost),
fixed_cost_allocation=float(project.cost_summary.fixed_cost_allocation),
)
return ResponseModel(
message="更新成功",
data=ProjectResponse(
id=project.id,
project_code=project.project_code,
project_name=project.project_name,
category_id=project.category_id,
category_name=category_name,
description=project.description,
duration_minutes=project.duration_minutes,
is_active=project.is_active,
cost_summary=cost_summary,
created_at=project.created_at,
updated_at=project.updated_at,
)
)
@router.delete("/{project_id}", response_model=ResponseModel)
async def delete_project(
project_id: int,
db: AsyncSession = Depends(get_db),
):
"""删除服务项目"""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "项目不存在"}
)
await db.delete(project)
return ResponseModel(message="删除成功")
# ============ 成本明细(耗材/设备)管理 ============
@router.get("/{project_id}/cost-items", response_model=ResponseModel[list[CostItemResponse]])
async def get_cost_items(
project_id: int,
db: AsyncSession = Depends(get_db),
):
"""获取项目成本明细"""
# 检查项目是否存在
project_result = await db.execute(
select(Project).where(Project.id == project_id)
)
if not project_result.scalar_one_or_none():
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "项目不存在"}
)
result = await db.execute(
select(ProjectCostItem).where(
ProjectCostItem.project_id == project_id
).order_by(ProjectCostItem.id)
)
items = result.scalars().all()
response_items = []
for item in items:
item_name = None
unit = None
if item.item_type == CostItemType.MATERIAL.value:
material_result = await db.execute(
select(Material).where(Material.id == item.item_id)
)
material = material_result.scalar_one_or_none()
if material:
item_name = material.material_name
unit = material.unit
else:
equipment_result = await db.execute(
select(Equipment).where(Equipment.id == item.item_id)
)
equipment = equipment_result.scalar_one_or_none()
if equipment:
item_name = equipment.equipment_name
unit = ""
response_items.append(CostItemResponse(
id=item.id,
item_type=CostItemType(item.item_type),
item_id=item.item_id,
item_name=item_name,
quantity=float(item.quantity),
unit=unit,
unit_cost=float(item.unit_cost),
total_cost=float(item.total_cost),
remark=item.remark,
created_at=item.created_at,
updated_at=item.updated_at,
))
return ResponseModel(data=response_items)
@router.post("/{project_id}/cost-items", response_model=ResponseModel[CostItemResponse])
async def create_cost_item(
project_id: int,
data: CostItemCreate,
db: AsyncSession = Depends(get_db),
):
"""添加成本明细"""
# 检查项目是否存在
project_result = await db.execute(
select(Project).where(Project.id == project_id)
)
if not project_result.scalar_one_or_none():
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "项目不存在"}
)
cost_service = CostService(db)
try:
cost_item = await cost_service.add_cost_item(
project_id=project_id,
item_type=data.item_type,
item_id=data.item_id,
quantity=data.quantity,
remark=data.remark,
)
except ValueError as e:
raise HTTPException(
status_code=400,
detail={"code": ErrorCode.NOT_FOUND, "message": str(e)}
)
# 获取物品名称
item_name = None
unit = None
if data.item_type == CostItemType.MATERIAL:
material_result = await db.execute(
select(Material).where(Material.id == data.item_id)
)
material = material_result.scalar_one_or_none()
if material:
item_name = material.material_name
unit = material.unit
else:
equipment_result = await db.execute(
select(Equipment).where(Equipment.id == data.item_id)
)
equipment = equipment_result.scalar_one_or_none()
if equipment:
item_name = equipment.equipment_name
unit = ""
return ResponseModel(
message="添加成功",
data=CostItemResponse(
id=cost_item.id,
item_type=CostItemType(cost_item.item_type),
item_id=cost_item.item_id,
item_name=item_name,
quantity=float(cost_item.quantity),
unit=unit,
unit_cost=float(cost_item.unit_cost),
total_cost=float(cost_item.total_cost),
remark=cost_item.remark,
created_at=cost_item.created_at,
updated_at=cost_item.updated_at,
)
)
@router.put("/{project_id}/cost-items/{item_id}", response_model=ResponseModel[CostItemResponse])
async def update_cost_item(
project_id: int,
item_id: int,
data: CostItemUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新成本明细"""
result = await db.execute(
select(ProjectCostItem).where(
ProjectCostItem.id == item_id,
ProjectCostItem.project_id == project_id,
)
)
cost_item = result.scalar_one_or_none()
if not cost_item:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "成本明细不存在"}
)
cost_service = CostService(db)
cost_item = await cost_service.update_cost_item(
cost_item=cost_item,
quantity=data.quantity,
remark=data.remark,
)
# 获取物品名称
item_name = None
unit = None
if cost_item.item_type == CostItemType.MATERIAL.value:
material_result = await db.execute(
select(Material).where(Material.id == cost_item.item_id)
)
material = material_result.scalar_one_or_none()
if material:
item_name = material.material_name
unit = material.unit
else:
equipment_result = await db.execute(
select(Equipment).where(Equipment.id == cost_item.item_id)
)
equipment = equipment_result.scalar_one_or_none()
if equipment:
item_name = equipment.equipment_name
unit = ""
return ResponseModel(
message="更新成功",
data=CostItemResponse(
id=cost_item.id,
item_type=CostItemType(cost_item.item_type),
item_id=cost_item.item_id,
item_name=item_name,
quantity=float(cost_item.quantity),
unit=unit,
unit_cost=float(cost_item.unit_cost),
total_cost=float(cost_item.total_cost),
remark=cost_item.remark,
created_at=cost_item.created_at,
updated_at=cost_item.updated_at,
)
)
@router.delete("/{project_id}/cost-items/{item_id}", response_model=ResponseModel)
async def delete_cost_item(
project_id: int,
item_id: int,
db: AsyncSession = Depends(get_db),
):
"""删除成本明细"""
result = await db.execute(
select(ProjectCostItem).where(
ProjectCostItem.id == item_id,
ProjectCostItem.project_id == project_id,
)
)
cost_item = result.scalar_one_or_none()
if not cost_item:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "成本明细不存在"}
)
await db.delete(cost_item)
return ResponseModel(message="删除成功")
# ============ 人工成本管理 ============
@router.get("/{project_id}/labor-costs", response_model=ResponseModel[list[LaborCostResponse]])
async def get_labor_costs(
project_id: int,
db: AsyncSession = Depends(get_db),
):
"""获取项目人工成本"""
# 检查项目是否存在
project_result = await db.execute(
select(Project).where(Project.id == project_id)
)
if not project_result.scalar_one_or_none():
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "项目不存在"}
)
result = await db.execute(
select(ProjectLaborCost).options(
selectinload(ProjectLaborCost.staff_level)
).where(
ProjectLaborCost.project_id == project_id
).order_by(ProjectLaborCost.id)
)
items = result.scalars().all()
response_items = []
for item in items:
response_items.append(LaborCostResponse(
id=item.id,
staff_level_id=item.staff_level_id,
level_name=item.staff_level.level_name if item.staff_level else None,
duration_minutes=item.duration_minutes,
hourly_rate=float(item.hourly_rate),
labor_cost=float(item.labor_cost),
remark=item.remark,
created_at=item.created_at,
updated_at=item.updated_at,
))
return ResponseModel(data=response_items)
@router.post("/{project_id}/labor-costs", response_model=ResponseModel[LaborCostResponse])
async def create_labor_cost(
project_id: int,
data: LaborCostCreate,
db: AsyncSession = Depends(get_db),
):
"""添加人工成本"""
# 检查项目是否存在
project_result = await db.execute(
select(Project).where(Project.id == project_id)
)
if not project_result.scalar_one_or_none():
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "项目不存在"}
)
cost_service = CostService(db)
try:
labor_cost = await cost_service.add_labor_cost(
project_id=project_id,
staff_level_id=data.staff_level_id,
duration_minutes=data.duration_minutes,
remark=data.remark,
)
except ValueError as e:
raise HTTPException(
status_code=400,
detail={"code": ErrorCode.NOT_FOUND, "message": str(e)}
)
# 获取级别名称
level_result = await db.execute(
select(StaffLevel).where(StaffLevel.id == data.staff_level_id)
)
staff_level = level_result.scalar_one_or_none()
return ResponseModel(
message="添加成功",
data=LaborCostResponse(
id=labor_cost.id,
staff_level_id=labor_cost.staff_level_id,
level_name=staff_level.level_name if staff_level else None,
duration_minutes=labor_cost.duration_minutes,
hourly_rate=float(labor_cost.hourly_rate),
labor_cost=float(labor_cost.labor_cost),
remark=labor_cost.remark,
created_at=labor_cost.created_at,
updated_at=labor_cost.updated_at,
)
)
@router.put("/{project_id}/labor-costs/{item_id}", response_model=ResponseModel[LaborCostResponse])
async def update_labor_cost(
project_id: int,
item_id: int,
data: LaborCostUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新人工成本"""
result = await db.execute(
select(ProjectLaborCost).where(
ProjectLaborCost.id == item_id,
ProjectLaborCost.project_id == project_id,
)
)
labor_item = result.scalar_one_or_none()
if not labor_item:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "人工成本记录不存在"}
)
cost_service = CostService(db)
try:
labor_item = await cost_service.update_labor_cost(
labor_item=labor_item,
staff_level_id=data.staff_level_id,
duration_minutes=data.duration_minutes,
remark=data.remark,
)
except ValueError as e:
raise HTTPException(
status_code=400,
detail={"code": ErrorCode.NOT_FOUND, "message": str(e)}
)
# 获取级别名称
level_result = await db.execute(
select(StaffLevel).where(StaffLevel.id == labor_item.staff_level_id)
)
staff_level = level_result.scalar_one_or_none()
return ResponseModel(
message="更新成功",
data=LaborCostResponse(
id=labor_item.id,
staff_level_id=labor_item.staff_level_id,
level_name=staff_level.level_name if staff_level else None,
duration_minutes=labor_item.duration_minutes,
hourly_rate=float(labor_item.hourly_rate),
labor_cost=float(labor_item.labor_cost),
remark=labor_item.remark,
created_at=labor_item.created_at,
updated_at=labor_item.updated_at,
)
)
@router.delete("/{project_id}/labor-costs/{item_id}", response_model=ResponseModel)
async def delete_labor_cost(
project_id: int,
item_id: int,
db: AsyncSession = Depends(get_db),
):
"""删除人工成本"""
result = await db.execute(
select(ProjectLaborCost).where(
ProjectLaborCost.id == item_id,
ProjectLaborCost.project_id == project_id,
)
)
labor_item = result.scalar_one_or_none()
if not labor_item:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "人工成本记录不存在"}
)
await db.delete(labor_item)
return ResponseModel(message="删除成功")
# ============ 成本计算 ============
@router.post("/{project_id}/calculate-cost", response_model=ResponseModel[CostCalculationResult])
async def calculate_cost(
project_id: int,
data: CalculateCostRequest = CalculateCostRequest(),
db: AsyncSession = Depends(get_db),
):
"""计算项目总成本"""
cost_service = CostService(db)
try:
result = await cost_service.calculate_project_cost(
project_id=project_id,
allocation_method=data.fixed_cost_allocation_method,
)
except ValueError as e:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": str(e)}
)
return ResponseModel(message="计算完成", data=result)
@router.get("/{project_id}/cost-summary", response_model=ResponseModel[CostSummaryResponse])
async def get_cost_summary(
project_id: int,
db: AsyncSession = Depends(get_db),
):
"""获取成本汇总"""
# 检查项目是否存在
project_result = await db.execute(
select(Project).where(Project.id == project_id)
)
if not project_result.scalar_one_or_none():
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "项目不存在"}
)
result = await db.execute(
select(ProjectCostSummary).where(
ProjectCostSummary.project_id == project_id
)
)
summary = result.scalar_one_or_none()
if not summary:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "成本汇总不存在,请先计算成本"}
)
return ResponseModel(
data=CostSummaryResponse(
project_id=summary.project_id,
material_cost=float(summary.material_cost),
equipment_cost=float(summary.equipment_cost),
labor_cost=float(summary.labor_cost),
fixed_cost_allocation=float(summary.fixed_cost_allocation),
total_cost=float(summary.total_cost),
calculated_at=summary.calculated_at,
)
)