Files
selfrelease 5278190750 feat: 凭证生成、成本分析、AI问答、前端页面、集成测试与E2E测试
- 后端: 凭证生成引擎、金蝶导出器、凭证模板服务
- 后端: 成本分析服务、AI问答服务
- 后端: 科目映射CRUD API、分析API、QA API
- 后端: 集成测试(认证/任务/凭证) 49个测试全部通过
- 前端: 凭证管理、成本分析、导出中心、知识库、系统设置页面
- 前端: AuthGuard认证守卫、Dashboard AI聊天功能
- 前端: Playwright E2E测试 16 passed, 1 skipped
- 基础设施: Docker Compose、Nginx反向代理、.env.example
- 文档: 用户手册、管理员手册、发布检查清单
2026-07-07 21:21:29 +08:00

357 lines
12 KiB
Python

"""
成本分析计算服务
计算人工成本总额、部门拆分、费用科目拆分、环比变化等
"""
from datetime import datetime
from decimal import Decimal
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.cost_analysis import CostAnalysis
from app.models.reconciliation_task import ReconciliationTask
from app.services.data_cleaner import DataCleaner
class CostSummary:
"""成本汇总结果"""
def __init__(
self,
total_cost: float = 0.0,
salary_cost: float = 0.0,
social_security_cost: float = 0.0,
fund_cost: float = 0.0,
employee_count: int = 0,
):
self.total_cost = total_cost
self.salary_cost = salary_cost
self.social_security_cost = social_security_cost
self.fund_cost = fund_cost
self.employee_count = employee_count
def to_dict(self) -> Dict[str, Any]:
return {
"total_cost": self.total_cost,
"salary_cost": self.salary_cost,
"social_security_cost": self.social_security_cost,
"fund_cost": self.fund_cost,
"employee_count": self.employee_count,
}
class DepartmentCost:
"""部门成本"""
def __init__(
self,
department: str,
employee_count: int = 0,
salary_cost: float = 0.0,
social_security_cost: float = 0.0,
fund_cost: float = 0.0,
):
self.department = department
self.employee_count = employee_count
self.salary_cost = salary_cost
self.social_security_cost = social_security_cost
self.fund_cost = fund_cost
@property
def total_cost(self) -> float:
return self.salary_cost + self.social_security_cost + self.fund_cost
def to_dict(self) -> Dict[str, Any]:
return {
"department": self.department,
"employee_count": self.employee_count,
"salary_cost": self.salary_cost,
"social_security_cost": self.social_security_cost,
"fund_cost": self.fund_cost,
"total_cost": self.total_cost,
}
class CostCalculatorService:
"""成本分析计算服务"""
def __init__(self, db: AsyncSession):
self.db = db
async def calculate_total_cost(self, task_id: int) -> CostSummary:
"""
计算人工成本总额
Args:
task_id: 对账任务ID
Returns:
成本汇总结果
"""
cleaned_data = await self._load_cleaned_data(task_id)
salary_cost = 0.0
social_security_cost = 0.0
fund_cost = 0.0
employee_count = 0
for record in cleaned_data:
salary_cost += float(record.get("应发工资", 0) or 0)
social_security_cost += float(record.get("养老保险(公司)", 0) or 0)
social_security_cost += float(record.get("医疗保险(公司)", 0) or 0)
social_security_cost += float(record.get("失业保险(公司)", 0) or 0)
fund_cost += float(record.get("公积金(公司)", 0) or 0)
employee_count += 1
total_cost = salary_cost + social_security_cost + fund_cost
return CostSummary(
total_cost=total_cost,
salary_cost=salary_cost,
social_security_cost=social_security_cost,
fund_cost=fund_cost,
employee_count=employee_count,
)
async def calculate_by_department(self, task_id: int) -> List[DepartmentCost]:
"""
按部门汇总人工成本
Args:
task_id: 对账任务ID
Returns:
部门成本列表
"""
cleaned_data = await self._load_cleaned_data(task_id)
dept_map: Dict[str, DepartmentCost] = {}
for record in cleaned_data:
department = record.get("部门", "未分配") or "未分配"
if department not in dept_map:
dept_map[department] = DepartmentCost(department=department)
dept = dept_map[department]
dept.employee_count += 1
dept.salary_cost += float(record.get("应发工资", 0) or 0)
dept.social_security_cost += float(record.get("养老保险(公司)", 0) or 0)
dept.social_security_cost += float(record.get("医疗保险(公司)", 0) or 0)
dept.social_security_cost += float(record.get("失业保险(公司)", 0) or 0)
dept.fund_cost += float(record.get("公积金(公司)", 0) or 0)
return list(dept_map.values())
async def calculate_by_expense_type(self, task_id: int) -> Dict[str, float]:
"""
按费用科目拆分人工成本
Args:
task_id: 对账任务ID
Returns:
费用科目 -> 金额 的映射
"""
dept_costs = await self.calculate_by_department(task_id)
expense_map: Dict[str, float] = {
"管理费用": 0.0,
"销售费用": 0.0,
"研发费用": 0.0,
"其他": 0.0,
}
dept_to_expense = {
"研发部": "研发费用",
"研发中心": "研发费用",
"技术部": "研发费用",
"销售部": "销售费用",
"市场部": "销售费用",
"商务部": "销售费用",
}
for dept in dept_costs:
expense_type = dept_to_expense.get(dept.department, "管理费用")
expense_map[expense_type] = expense_map.get(expense_type, 0.0) + dept.total_cost
return expense_map
async def calculate_month_over_month(
self, task_id: int, prev_task_id: int
) -> Dict[str, Any]:
"""
计算环比变化
Args:
task_id: 当前任务ID
prev_task_id: 上月任务ID
Returns:
环比变化数据
"""
current = await self.calculate_total_cost(task_id)
previous = await self.calculate_total_cost(prev_task_id)
def _calc_change(curr: float, prev: float) -> Dict[str, Any]:
amount_change = curr - prev
ratio_change = (amount_change / prev * 100) if prev > 0 else 0.0
return {
"current": curr,
"previous": prev,
"amount_change": amount_change,
"ratio_change": round(ratio_change, 2),
}
return {
"total_cost": _calc_change(current.total_cost, previous.total_cost),
"salary_cost": _calc_change(current.salary_cost, previous.salary_cost),
"social_security_cost": _calc_change(
current.social_security_cost, previous.social_security_cost
),
"fund_cost": _calc_change(current.fund_cost, previous.fund_cost),
"employee_count": {
"current": current.employee_count,
"previous": previous.employee_count,
"amount_change": current.employee_count - previous.employee_count,
},
}
async def analyze_cost_changes(
self, curr_task_id: int, prev_task_id: int
) -> Dict[str, Any]:
"""
分析成本变化原因
Args:
curr_task_id: 当前任务ID
prev_task_id: 上月任务ID
Returns:
变化分析结果,包含新增/离职员工影响、薪资调整影响等
"""
curr_data = await self._load_cleaned_data(curr_task_id)
prev_data = await self._load_cleaned_data(prev_task_id)
curr_map = {r.get("员工姓名", ""): r for r in curr_data if r.get("员工姓名")}
prev_map = {r.get("员工姓名", ""): r for r in prev_data if r.get("员工姓名")}
new_employees = []
left_employees = []
salary_adjustments = []
for name, record in curr_map.items():
if name not in prev_map:
new_employees.append({
"name": name,
"salary": float(record.get("应发工资", 0) or 0),
})
else:
prev_salary = float(prev_map[name].get("应发工资", 0) or 0)
curr_salary = float(record.get("应发工资", 0) or 0)
if abs(curr_salary - prev_salary) > 0.01:
salary_adjustments.append({
"name": name,
"previous": prev_salary,
"current": curr_salary,
"change": curr_salary - prev_salary,
})
for name, record in prev_map.items():
if name not in curr_map:
left_employees.append({
"name": name,
"salary": float(record.get("应发工资", 0) or 0),
})
new_cost = sum(e["salary"] for e in new_employees)
left_cost = sum(e["salary"] for e in left_employees)
adjustment_cost = sum(a["change"] for a in salary_adjustments)
return {
"new_employees": new_employees,
"left_employees": left_employees,
"salary_adjustments": salary_adjustments,
"new_employee_cost": new_cost,
"left_employee_saving": left_cost,
"adjustment_cost": adjustment_cost,
"net_change": new_cost - left_cost + adjustment_cost,
}
async def save_analysis(
self,
task_id: int,
company_id: int,
summary: CostSummary,
department_breakdown: Optional[List[DepartmentCost]] = None,
expense_breakdown: Optional[Dict[str, float]] = None,
month_over_month: Optional[Dict[str, Any]] = None,
ai_summary: Optional[str] = None,
) -> CostAnalysis:
"""
保存成本分析结果到数据库
Args:
task_id: 任务ID
company_id: 企业ID
summary: 成本汇总
department_breakdown: 部门拆分
expense_breakdown: 费用科目拆分
month_over_month: 环比变化
ai_summary: AI 摘要
Returns:
创建的成本分析记录
"""
analysis = CostAnalysis(
task_id=task_id,
company_id=company_id,
total_cost=summary.total_cost,
salary_cost=summary.salary_cost,
social_security_cost=summary.social_security_cost,
fund_cost=summary.fund_cost,
department_breakdown=(
[d.to_dict() for d in department_breakdown] if department_breakdown else None
),
expense_breakdown=expense_breakdown,
month_over_month=month_over_month,
ai_summary=ai_summary,
)
self.db.add(analysis)
await self.db.commit()
await self.db.refresh(analysis)
return analysis
async def get_analysis(self, task_id: int) -> Optional[CostAnalysis]:
"""获取任务的成本分析结果"""
result = await self.db.execute(
select(CostAnalysis)
.where(CostAnalysis.task_id == task_id)
.order_by(CostAnalysis.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def _load_cleaned_data(self, task_id: int) -> List[Dict[str, Any]]:
"""
加载清洗后的数据
Args:
task_id: 任务ID
Returns:
清洗后的数据列表
"""
task = await self.db.get(ReconciliationTask, task_id)
if not task:
return []
if task.reconciliation_result and isinstance(task.reconciliation_result, dict):
records = task.reconciliation_result.get("records", [])
if records:
return records
return []