257 lines
8.2 KiB
Python
257 lines
8.2 KiB
Python
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))
|
|
|