feat: 凭证生成、成本分析、AI问答、前端页面、集成测试与E2E测试
- 后端: 凭证生成引擎、金蝶导出器、凭证模板服务 - 后端: 成本分析服务、AI问答服务 - 后端: 科目映射CRUD API、分析API、QA API - 后端: 集成测试(认证/任务/凭证) 49个测试全部通过 - 前端: 凭证管理、成本分析、导出中心、知识库、系统设置页面 - 前端: AuthGuard认证守卫、Dashboard AI聊天功能 - 前端: Playwright E2E测试 16 passed, 1 skipped - 基础设施: Docker Compose、Nginx反向代理、.env.example - 文档: 用户手册、管理员手册、发布检查清单
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
成本分析 API
|
||||
|
||||
提供人工成本分析、部门拆分、费用科目拆分、环比变化等接口
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.tenant import get_current_company_id
|
||||
from app.services.analysis.cost_calculator import CostCalculatorService
|
||||
from app.services.ai.cost_analyzer import CostAnalyzerService
|
||||
|
||||
router = APIRouter(prefix="/api/analysis", tags=["成本分析"])
|
||||
|
||||
|
||||
class CostSummaryResponse(BaseModel):
|
||||
"""成本汇总响应"""
|
||||
total_cost: float
|
||||
salary_cost: float
|
||||
social_security_cost: float
|
||||
fund_cost: float
|
||||
employee_count: int
|
||||
|
||||
|
||||
class DepartmentCostResponse(BaseModel):
|
||||
"""部门成本响应"""
|
||||
department: str
|
||||
employee_count: int
|
||||
salary_cost: float
|
||||
social_security_cost: float
|
||||
fund_cost: float
|
||||
total_cost: float
|
||||
|
||||
|
||||
class ExpenseBreakdownResponse(BaseModel):
|
||||
"""费用科目拆分响应"""
|
||||
expense_type: str
|
||||
amount: float
|
||||
|
||||
|
||||
class MonthOverMonthResponse(BaseModel):
|
||||
"""环比变化响应"""
|
||||
total_cost: Dict[str, Any]
|
||||
salary_cost: Dict[str, Any]
|
||||
social_security_cost: Dict[str, Any]
|
||||
fund_cost: Dict[str, Any]
|
||||
employee_count: Dict[str, Any]
|
||||
|
||||
|
||||
class CostChangeAnalysisResponse(BaseModel):
|
||||
"""成本变化分析响应"""
|
||||
new_employees: List[Dict[str, Any]]
|
||||
left_employees: List[Dict[str, Any]]
|
||||
salary_adjustments: List[Dict[str, Any]]
|
||||
new_employee_cost: float
|
||||
left_employee_saving: float
|
||||
adjustment_cost: float
|
||||
net_change: float
|
||||
|
||||
|
||||
class FullAnalysisResponse(BaseModel):
|
||||
"""完整成本分析响应"""
|
||||
summary: CostSummaryResponse
|
||||
departments: List[DepartmentCostResponse]
|
||||
expenses: List[ExpenseBreakdownResponse]
|
||||
changes: Optional[Dict[str, Any]] = None
|
||||
ai_summary: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/labor-cost/{task_id}", response_model=FullAnalysisResponse)
|
||||
async def get_labor_cost_analysis(
|
||||
task_id: int,
|
||||
prev_task_id: Optional[int] = Query(None, description="上月任务ID,用于环比"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> FullAnalysisResponse:
|
||||
"""
|
||||
获取人工成本分析
|
||||
|
||||
包含总额、部门拆分、费用科目拆分,可选环比
|
||||
"""
|
||||
service = CostCalculatorService(db)
|
||||
|
||||
summary = await service.calculate_total_cost(task_id)
|
||||
departments = await service.calculate_by_department(task_id)
|
||||
expenses = await service.calculate_by_expense_type(task_id)
|
||||
|
||||
changes = None
|
||||
ai_summary = None
|
||||
|
||||
if prev_task_id:
|
||||
changes = await service.calculate_month_over_month(task_id, prev_task_id)
|
||||
change_detail = await service.analyze_cost_changes(task_id, prev_task_id)
|
||||
|
||||
# 尝试 AI 分析
|
||||
try:
|
||||
analyzer = CostAnalyzerService()
|
||||
prev_summary = await service.calculate_total_cost(prev_task_id)
|
||||
ai_summary = await analyzer.analyze_cost_changes(
|
||||
summary.to_dict(),
|
||||
prev_summary.to_dict(),
|
||||
change_detail,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return FullAnalysisResponse(
|
||||
summary=CostSummaryResponse(**summary.to_dict()),
|
||||
departments=[
|
||||
DepartmentCostResponse(**d.to_dict()) for d in departments
|
||||
],
|
||||
expenses=[
|
||||
ExpenseBreakdownResponse(expense_type=k, amount=v)
|
||||
for k, v in expenses.items()
|
||||
],
|
||||
changes=changes,
|
||||
ai_summary=ai_summary,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/labor-cost/{task_id}/summary", response_model=CostSummaryResponse)
|
||||
async def get_cost_summary(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> CostSummaryResponse:
|
||||
"""获取成本汇总"""
|
||||
service = CostCalculatorService(db)
|
||||
summary = await service.calculate_total_cost(task_id)
|
||||
return CostSummaryResponse(**summary.to_dict())
|
||||
|
||||
|
||||
@router.get("/labor-cost/{task_id}/departments", response_model=List[DepartmentCostResponse])
|
||||
async def get_department_costs(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> List[DepartmentCostResponse]:
|
||||
"""获取部门成本拆分"""
|
||||
service = CostCalculatorService(db)
|
||||
departments = await service.calculate_by_department(task_id)
|
||||
return [DepartmentCostResponse(**d.to_dict()) for d in departments]
|
||||
|
||||
|
||||
@router.get("/labor-cost/{task_id}/expenses", response_model=List[ExpenseBreakdownResponse])
|
||||
async def get_expense_breakdown(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> List[ExpenseBreakdownResponse]:
|
||||
"""获取费用科目拆分"""
|
||||
service = CostCalculatorService(db)
|
||||
expenses = await service.calculate_by_expense_type(task_id)
|
||||
return [
|
||||
ExpenseBreakdownResponse(expense_type=k, amount=v)
|
||||
for k, v in expenses.items()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/labor-cost/{task_id}/changes", response_model=CostChangeAnalysisResponse)
|
||||
async def get_cost_changes(
|
||||
task_id: int,
|
||||
prev_task_id: int = Query(..., description="上月任务ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> CostChangeAnalysisResponse:
|
||||
"""获取成本变化分析"""
|
||||
service = CostCalculatorService(db)
|
||||
changes = await service.analyze_cost_changes(task_id, prev_task_id)
|
||||
return CostChangeAnalysisResponse(**changes)
|
||||
|
||||
|
||||
@router.get("/labor-cost/{task_id}/export")
|
||||
async def export_cost_analysis(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> StreamingResponse:
|
||||
"""
|
||||
导出成本分析为 Excel
|
||||
|
||||
返回包含成本汇总、部门拆分、费用拆分的 Excel 文件
|
||||
"""
|
||||
service = CostCalculatorService(db)
|
||||
summary = await service.calculate_total_cost(task_id)
|
||||
departments = await service.calculate_by_department(task_id)
|
||||
expenses = await service.calculate_by_expense_type(task_id)
|
||||
|
||||
# 使用 openpyxl 生成 Excel
|
||||
from openpyxl import Workbook
|
||||
|
||||
wb = Workbook()
|
||||
|
||||
# Sheet1: 成本汇总
|
||||
ws1 = wb.active
|
||||
ws1.title = "成本汇总"
|
||||
ws1.append(["项目", "金额(元)"])
|
||||
ws1.append(["人工成本总额", summary.total_cost])
|
||||
ws1.append(["工资成本", summary.salary_cost])
|
||||
ws1.append(["社保成本(公司部分)", summary.social_security_cost])
|
||||
ws1.append(["公积金成本(公司部分)", summary.fund_cost])
|
||||
ws1.append(["员工人数", summary.employee_count])
|
||||
|
||||
# Sheet2: 部门拆分
|
||||
ws2 = wb.create_sheet("部门拆分")
|
||||
ws2.append(["部门", "人数", "工资成本", "社保成本", "公积金成本", "合计"])
|
||||
for dept in departments:
|
||||
ws2.append([
|
||||
dept.department,
|
||||
dept.employee_count,
|
||||
dept.salary_cost,
|
||||
dept.social_security_cost,
|
||||
dept.fund_cost,
|
||||
dept.total_cost,
|
||||
])
|
||||
|
||||
# Sheet3: 费用科目拆分
|
||||
ws3 = wb.create_sheet("费用科目拆分")
|
||||
ws3.append(["费用科目", "金额(元)"])
|
||||
for expense_type, amount in expenses.items():
|
||||
ws3.append([expense_type, amount])
|
||||
|
||||
# 输出到内存
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
|
||||
filename = f"cost_analysis_{task_id}.xlsx"
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
问答 API
|
||||
|
||||
提供预置问题列表和问题回答接口
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.tenant import get_current_company_id
|
||||
from app.services.ai.qa_service import QAService
|
||||
|
||||
router = APIRouter(prefix="/api/qa", tags=["问答"])
|
||||
|
||||
|
||||
class QuestionRequest(BaseModel):
|
||||
"""问题请求"""
|
||||
task_id: int
|
||||
question: str
|
||||
context: str = ""
|
||||
|
||||
|
||||
class QuestionResponse(BaseModel):
|
||||
"""问题响应"""
|
||||
answer: str
|
||||
data_points: List[str] = []
|
||||
|
||||
|
||||
class SuggestedQuestionsResponse(BaseModel):
|
||||
"""建议问题响应"""
|
||||
questions: List[str]
|
||||
|
||||
|
||||
@router.get("/suggested-questions", response_model=SuggestedQuestionsResponse)
|
||||
async def get_suggested_questions(
|
||||
task_id: int,
|
||||
context: str = "",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> SuggestedQuestionsResponse:
|
||||
"""
|
||||
获取预置问题列表
|
||||
|
||||
根据任务状态返回合适的预置问题
|
||||
"""
|
||||
service = QAService(db)
|
||||
questions = await service.get_suggested_questions(task_id, context)
|
||||
return SuggestedQuestionsResponse(questions=questions)
|
||||
|
||||
|
||||
@router.post("/ask", response_model=QuestionResponse)
|
||||
async def ask_question(
|
||||
request: QuestionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> QuestionResponse:
|
||||
"""
|
||||
回答用户问题
|
||||
|
||||
基于真实数据回答预置问题
|
||||
"""
|
||||
service = QAService(db)
|
||||
answer = await service.answer_preset_question(request.task_id, request.question)
|
||||
return QuestionResponse(answer=answer, data_points=[])
|
||||
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
凭证 API
|
||||
|
||||
提供凭证生成、查询、确认和金蝶导出接口
|
||||
"""
|
||||
|
||||
import io
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.tenant import get_current_company_id
|
||||
from app.models.voucher import Voucher, VoucherStatus
|
||||
from app.models.account_mapping import AccountMapping
|
||||
from app.services.voucher.generator import VoucherGeneratorService
|
||||
from app.services.voucher.kingdee_exporter import KingdeeExporterService
|
||||
from app.services.voucher.template import VoucherTemplate
|
||||
|
||||
router = APIRouter(prefix="/api/vouchers", tags=["凭证管理"])
|
||||
|
||||
|
||||
class VoucherEntryResponse(BaseModel):
|
||||
"""凭证分录响应"""
|
||||
account_code: str
|
||||
account_name: str
|
||||
debit_amount: float
|
||||
credit_amount: float
|
||||
summary: str
|
||||
department: str = ""
|
||||
|
||||
|
||||
class VoucherResponse(BaseModel):
|
||||
"""凭证响应"""
|
||||
id: int
|
||||
voucher_number: str
|
||||
voucher_date: str
|
||||
period: str
|
||||
summary: str
|
||||
entries: List[Dict[str, Any]]
|
||||
total_debit: float
|
||||
total_credit: float
|
||||
status: str
|
||||
confirmed_by: Optional[int] = None
|
||||
confirmed_at: Optional[str] = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class GenerateVoucherRequest(BaseModel):
|
||||
"""生成凭证请求"""
|
||||
task_id: int
|
||||
period: str = ""
|
||||
|
||||
|
||||
class ConfirmVoucherRequest(BaseModel):
|
||||
"""确认凭证请求"""
|
||||
user_id: int
|
||||
|
||||
|
||||
class AccountMappingResponse(BaseModel):
|
||||
"""科目映射响应"""
|
||||
id: int
|
||||
standard_field: str
|
||||
debit_account: str
|
||||
debit_account_name: str
|
||||
credit_account: str
|
||||
credit_account_name: str
|
||||
cost_center: Optional[str] = None
|
||||
is_active: bool
|
||||
|
||||
|
||||
class AccountMappingRequest(BaseModel):
|
||||
"""科目映射请求"""
|
||||
standard_field: str
|
||||
debit_account: str
|
||||
debit_account_name: str
|
||||
credit_account: str
|
||||
credit_account_name: str
|
||||
cost_center: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/generate", response_model=VoucherResponse)
|
||||
async def generate_voucher(
|
||||
request: GenerateVoucherRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> VoucherResponse:
|
||||
"""生成会计凭证"""
|
||||
service = VoucherGeneratorService(db)
|
||||
|
||||
# 检查是否已有凭证
|
||||
existing = await service.get_voucher(request.task_id)
|
||||
if existing:
|
||||
return _to_response(existing)
|
||||
|
||||
voucher = await service.generate_voucher(
|
||||
task_id=request.task_id,
|
||||
company_id=company_id,
|
||||
period=request.period,
|
||||
)
|
||||
return _to_response(voucher)
|
||||
|
||||
|
||||
@router.get("/task/{task_id}", response_model=Optional[VoucherResponse])
|
||||
async def get_task_voucher(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> Optional[VoucherResponse]:
|
||||
"""获取任务的凭证"""
|
||||
service = VoucherGeneratorService(db)
|
||||
voucher = await service.get_voucher(task_id)
|
||||
if not voucher:
|
||||
return None
|
||||
return _to_response(voucher)
|
||||
|
||||
|
||||
@router.get("/{voucher_id}", response_model=VoucherResponse)
|
||||
async def get_voucher(
|
||||
voucher_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> VoucherResponse:
|
||||
"""获取凭证详情"""
|
||||
voucher = await db.get(Voucher, voucher_id)
|
||||
if not voucher:
|
||||
raise HTTPException(status_code=404, detail="凭证不存在")
|
||||
return _to_response(voucher)
|
||||
|
||||
|
||||
@router.post("/{voucher_id}/confirm", response_model=VoucherResponse)
|
||||
async def confirm_voucher(
|
||||
voucher_id: int,
|
||||
request: ConfirmVoucherRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> VoucherResponse:
|
||||
"""确认凭证"""
|
||||
service = VoucherGeneratorService(db)
|
||||
voucher = await service.confirm_voucher(voucher_id, request.user_id)
|
||||
if not voucher:
|
||||
raise HTTPException(status_code=404, detail="凭证不存在")
|
||||
return _to_response(voucher)
|
||||
|
||||
|
||||
@router.get("/task/{task_id}/export")
|
||||
async def export_voucher(
|
||||
task_id: int,
|
||||
format: str = Query("csv", description="导出格式: csv 或 excel"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> StreamingResponse:
|
||||
"""导出凭证为金蝶格式"""
|
||||
service = KingdeeExporterService(db)
|
||||
try:
|
||||
return await service.export_task_vouchers(task_id, format)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
# ===== 科目映射管理 =====
|
||||
|
||||
@router.get("/account-mappings/list", response_model=List[AccountMappingResponse])
|
||||
async def list_account_mappings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> List[AccountMappingResponse]:
|
||||
"""获取科目映射列表"""
|
||||
result = await db.execute(
|
||||
select(AccountMapping).where(AccountMapping.company_id == company_id)
|
||||
)
|
||||
mappings = result.scalars().all()
|
||||
return [AccountMappingResponse(**m.__dict__) for m in mappings]
|
||||
|
||||
|
||||
@router.post("/account-mappings", response_model=AccountMappingResponse)
|
||||
async def create_account_mapping(
|
||||
request: AccountMappingRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> AccountMappingResponse:
|
||||
"""创建科目映射"""
|
||||
mapping = AccountMapping(
|
||||
company_id=company_id,
|
||||
standard_field=request.standard_field,
|
||||
debit_account=request.debit_account,
|
||||
debit_account_name=request.debit_account_name,
|
||||
credit_account=request.credit_account,
|
||||
credit_account_name=request.credit_account_name,
|
||||
cost_center=request.cost_center,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(mapping)
|
||||
await db.commit()
|
||||
await db.refresh(mapping)
|
||||
return AccountMappingResponse(**mapping.__dict__)
|
||||
|
||||
|
||||
@router.put("/account-mappings/{mapping_id}", response_model=AccountMappingResponse)
|
||||
async def update_account_mapping(
|
||||
mapping_id: int,
|
||||
request: AccountMappingRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> AccountMappingResponse:
|
||||
"""更新科目映射"""
|
||||
mapping = await db.get(AccountMapping, mapping_id)
|
||||
if not mapping:
|
||||
raise HTTPException(status_code=404, detail="科目映射不存在")
|
||||
|
||||
mapping.standard_field = request.standard_field
|
||||
mapping.debit_account = request.debit_account
|
||||
mapping.debit_account_name = request.debit_account_name
|
||||
mapping.credit_account = request.credit_account
|
||||
mapping.credit_account_name = request.credit_account_name
|
||||
mapping.cost_center = request.cost_center
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(mapping)
|
||||
return AccountMappingResponse(**mapping.__dict__)
|
||||
|
||||
|
||||
@router.delete("/account-mappings/{mapping_id}")
|
||||
async def delete_account_mapping(
|
||||
mapping_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> Dict[str, str]:
|
||||
"""删除科目映射"""
|
||||
mapping = await db.get(AccountMapping, mapping_id)
|
||||
if not mapping:
|
||||
raise HTTPException(status_code=404, detail="科目映射不存在")
|
||||
|
||||
await db.delete(mapping)
|
||||
await db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.get("/templates/list")
|
||||
async def get_voucher_templates() -> Dict[str, Any]:
|
||||
"""获取默认凭证模板"""
|
||||
return VoucherTemplate.get_all_templates()
|
||||
|
||||
|
||||
def _to_response(voucher: Voucher) -> VoucherResponse:
|
||||
"""转换凭证模型为响应"""
|
||||
return VoucherResponse(
|
||||
id=voucher.id,
|
||||
voucher_number=voucher.voucher_number,
|
||||
voucher_date=voucher.voucher_date,
|
||||
period=voucher.period,
|
||||
summary=voucher.summary,
|
||||
entries=voucher.entries or [],
|
||||
total_debit=voucher.total_debit,
|
||||
total_credit=voucher.total_credit,
|
||||
status=voucher.status,
|
||||
confirmed_by=voucher.confirmed_by,
|
||||
confirmed_at=voucher.confirmed_at.isoformat() if voucher.confirmed_at else None,
|
||||
created_at=voucher.created_at.isoformat() if voucher.created_at else "",
|
||||
updated_at=voucher.updated_at.isoformat() if voucher.updated_at else "",
|
||||
)
|
||||
Reference in New Issue
Block a user