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:
selfrelease
2026-07-07 21:21:29 +08:00
parent feebbb10ac
commit 5278190750
47 changed files with 6567 additions and 156 deletions
+241
View File
@@ -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}"'},
)
+68
View File
@@ -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=[])
+266
View File
@@ -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 "",
)