This commit is contained in:
丹尼尔
2026-03-12 19:35:06 +08:00
commit ad96272ab6
40 changed files with 2645 additions and 0 deletions

View File

@@ -0,0 +1,256 @@
from pathlib import Path
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from backend.app import models
from backend.app.db import get_db
from backend.app.schemas import (
ContractGenerateRequest,
ContractGenerateResponse,
ProjectRead,
ProjectUpdate,
QuoteGenerateResponse,
RequirementAnalyzeRequest,
RequirementAnalyzeResponse,
)
from backend.app.services.ai_service import analyze_requirement
from backend.app.services.doc_service import (
generate_contract_word,
generate_quote_excel,
generate_quote_pdf_from_data,
)
router = APIRouter(prefix="/projects", tags=["projects"])
def _build_markdown_from_analysis(data: Dict[str, Any]) -> str:
"""
Convert structured AI analysis JSON into a human-editable Markdown document.
"""
lines: list[str] = []
lines.append("# 项目方案草稿")
lines.append("")
total_hours = data.get("total_estimated_hours")
total_amount = data.get("total_amount")
if total_hours is not None or total_amount is not None:
lines.append("## 概要")
if total_hours is not None:
lines.append(f"- 建议总工时:**{total_hours}**")
if total_amount is not None:
lines.append(f"- 建议总报价:**{total_amount}**")
lines.append("")
modules = data.get("modules") or []
if modules:
lines.append("## 功能模块与技术实现")
for idx, module in enumerate(modules, start=1):
name = module.get("name", f"模块 {idx}")
desc = module.get("description") or ""
tech = module.get("technical_approach") or ""
hours = module.get("estimated_hours")
unit_price = module.get("unit_price")
subtotal = module.get("subtotal")
lines.append(f"### {idx}. {name}")
if desc:
lines.append(desc)
lines.append("")
if tech:
lines.append("**技术实现思路:**")
lines.append(tech)
lines.append("")
if hours is not None or unit_price is not None or subtotal is not None:
lines.append("**工时与报价:**")
if hours is not None:
lines.append(f"- 预估工时:{hours}")
if unit_price is not None:
lines.append(f"- 单价:{unit_price}")
if subtotal is not None:
lines.append(f"- 小计:{subtotal}")
lines.append("")
notes = data.get("notes")
if notes:
lines.append("## 备注")
lines.append(notes)
lines.append("")
return "\n".join(lines)
@router.get("/", response_model=list[ProjectRead])
async def list_projects(db: Session = Depends(get_db)):
projects = (
db.query(models.Project)
.order_by(models.Project.created_at.desc())
.all()
)
return projects
@router.get("/{project_id}", response_model=ProjectRead)
async def get_project(project_id: int, db: Session = Depends(get_db)):
project = db.query(models.Project).get(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return project
@router.patch("/{project_id}", response_model=ProjectRead)
async def update_project(
project_id: int,
payload: ProjectUpdate,
db: Session = Depends(get_db),
):
project = db.query(models.Project).get(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
if payload.ai_solution_md is not None:
project.ai_solution_md = payload.ai_solution_md
if payload.status is not None:
project.status = payload.status
db.commit()
db.refresh(project)
return project
@router.post("/analyze", response_model=RequirementAnalyzeResponse)
async def analyze_project_requirement(
payload: RequirementAnalyzeRequest,
db: Session = Depends(get_db),
):
# Ensure customer exists
customer = db.query(models.Customer).get(payload.customer_id)
if not customer:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
analysis = await analyze_requirement(payload.raw_text)
ai_solution_md = _build_markdown_from_analysis(analysis)
project = models.Project(
customer_id=payload.customer_id,
raw_requirement=payload.raw_text,
ai_solution_md=ai_solution_md,
status="draft",
)
db.add(project)
db.commit()
db.refresh(project)
return RequirementAnalyzeResponse(
project_id=project.id,
ai_solution_md=ai_solution_md,
ai_solution_json=analysis,
)
@router.post("/{project_id}/generate_quote", response_model=QuoteGenerateResponse)
async def generate_project_quote(
project_id: int,
db: Session = Depends(get_db),
):
project = db.query(models.Project).get(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
# Re-analyze to get fresh structured data (the UI will allow user to edit Markdown separately).
analysis = await analyze_requirement(project.raw_requirement)
# Paths
base_dir = Path("data/quotes")
base_dir.mkdir(parents=True, exist_ok=True)
excel_path = base_dir / f"quote_project_{project.id}.xlsx"
pdf_path = base_dir / f"quote_project_{project.id}.pdf"
template_path = Path("templates/quote_template.xlsx")
if not template_path.exists():
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Quote template file not found on server.",
)
await generate_quote_excel(analysis, str(template_path), str(excel_path))
await generate_quote_pdf_from_data(analysis, str(pdf_path))
total_amount = float(analysis.get("total_amount") or 0.0)
quote = models.Quote(
project_id=project.id,
total_amount=total_amount,
file_path=str(pdf_path),
)
db.add(quote)
db.commit()
db.refresh(quote)
return QuoteGenerateResponse(
quote_id=quote.id,
project_id=project.id,
total_amount=total_amount,
excel_path=str(excel_path),
pdf_path=str(pdf_path),
)
@router.post(
"/{project_id}/generate_contract",
response_model=ContractGenerateResponse,
)
async def generate_project_contract(
project_id: int,
payload: ContractGenerateRequest,
db: Session = Depends(get_db),
):
project = db.query(models.Project).get(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
customer = project.customer
if not customer:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Project has no associated customer"
)
# Use the latest quote for this project to determine total price.
latest_quote = (
db.query(models.Quote)
.filter(models.Quote.project_id == project.id)
.order_by(models.Quote.created_at.desc())
.first()
)
if not latest_quote:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No quote found for this project.",
)
contracts_dir = Path("data/contracts")
contracts_dir.mkdir(parents=True, exist_ok=True)
output_path = contracts_dir / f"contract_project_{project.id}.docx"
template_path = Path("templates/contract_template.docx")
if not template_path.exists():
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Contract template file not found on server.",
)
# Build placeholder mapping
mapping: Dict[str, str] = {
"{{CUSTOMER_NAME}}": customer.name,
"{{TOTAL_PRICE}}": str(latest_quote.total_amount),
"{{DELIVERY_DATE}}": payload.delivery_date,
}
# Allow arbitrary additional placeholders
for key, value in payload.extra_placeholders.items():
mapping[key] = value
await generate_contract_word(mapping, str(template_path), str(output_path))
return ContractGenerateResponse(project_id=project.id, contract_path=str(output_path))