370 lines
13 KiB
Python
370 lines
13 KiB
Python
import logging
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Union
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from sqlalchemy.orm import Session, joinedload
|
||
|
||
from backend.app import models
|
||
from backend.app.db import get_db
|
||
from backend.app.schemas import (
|
||
ContractGenerateRequest,
|
||
ContractGenerateResponse,
|
||
ProjectRead,
|
||
ProjectUpdate,
|
||
PushToCloudRequest,
|
||
PushToCloudResponse,
|
||
QuoteGenerateResponse,
|
||
RequirementAnalyzeRequest,
|
||
RequirementAnalyzeResponse,
|
||
)
|
||
from backend.app.services.ai_service import analyze_requirement
|
||
from backend.app.services.cloud_doc_service import CloudDocManager
|
||
from backend.app.services.doc_service import (
|
||
generate_contract_word,
|
||
generate_quote_excel,
|
||
generate_quote_pdf_from_data,
|
||
)
|
||
from backend.app.routers.cloud_doc_config import get_all_credentials
|
||
|
||
|
||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||
|
||
|
||
def _build_markdown_from_analysis(data: Union[Dict[str, Any], List[Any]]) -> str:
|
||
"""
|
||
Convert structured AI analysis JSON into a human-editable Markdown document.
|
||
Tolerates AI returning a list (e.g. modules only) and normalizes to a dict.
|
||
"""
|
||
if isinstance(data, list):
|
||
data = {"modules": data, "total_estimated_hours": None, "total_amount": None, "notes": None}
|
||
if not isinstance(data, dict):
|
||
data = {}
|
||
|
||
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):
|
||
if not isinstance(module, dict):
|
||
# AI sometimes returns strings or other shapes; treat as a single title line
|
||
raw_name = str(module).strip() if module else ""
|
||
name = raw_name if len(raw_name) > 1 and raw_name not in (":", "[", "{", "}") else f"模块 {idx}"
|
||
lines.append(f"### {idx}. {name}")
|
||
lines.append("")
|
||
continue
|
||
raw_name = (module.get("name") or "").strip()
|
||
name = raw_name if len(raw_name) > 1 and raw_name not in (":", "[", "{", "}") else 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(
|
||
customer_tag: str | None = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""列表项目;customer_tag 不为空时只返回该客户标签下的项目(按客户 tags 筛选)。"""
|
||
query = (
|
||
db.query(models.Project)
|
||
.options(joinedload(models.Project.customer))
|
||
.join(models.Customer)
|
||
.order_by(models.Project.created_at.desc())
|
||
)
|
||
if customer_tag and customer_tag.strip():
|
||
tag = customer_tag.strip()
|
||
# 客户 tags 逗号分隔,按整词匹配
|
||
from sqlalchemy import or_
|
||
t = models.Customer.tags
|
||
query = query.filter(
|
||
or_(
|
||
t == tag,
|
||
t.ilike(f"{tag},%"),
|
||
t.ilike(f"%,{tag},%"),
|
||
t.ilike(f"%,{tag}"),
|
||
)
|
||
)
|
||
return query.all()
|
||
|
||
|
||
@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.raw_requirement is not None:
|
||
project.raw_requirement = payload.raw_requirement
|
||
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),
|
||
):
|
||
logging.getLogger(__name__).info(
|
||
"收到 AI 解析请求: customer_id=%s, 需求长度=%d",
|
||
payload.customer_id,
|
||
len(payload.raw_text or ""),
|
||
)
|
||
# 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")
|
||
|
||
try:
|
||
analysis = await analyze_requirement(payload.raw_text)
|
||
except RuntimeError as e:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=str(e),
|
||
) from e
|
||
|
||
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,
|
||
template: str | None = None,
|
||
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"
|
||
|
||
from backend.app.routers.settings import get_quote_template_path
|
||
|
||
template_path = get_quote_template_path(template)
|
||
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))
|
||
|
||
|
||
@router.post("/{project_id}/push-to-cloud", response_model=PushToCloudResponse)
|
||
async def push_project_to_cloud(
|
||
project_id: int,
|
||
payload: PushToCloudRequest,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
将当前项目方案(Markdown)推送到云文档。若该项目此前已推送过该平台,则更新原文档(增量同步)。
|
||
"""
|
||
project = db.query(models.Project).options(joinedload(models.Project.customer)).get(project_id)
|
||
if not project:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||
platform = (payload.platform or "").strip().lower()
|
||
if platform not in ("feishu", "yuque", "tencent"):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="platform 须为 feishu / yuque / tencent",
|
||
)
|
||
title = (payload.title or "").strip() or f"项目方案 - 项目#{project_id}"
|
||
body_md = (payload.body_md if payload.body_md is not None else project.ai_solution_md) or ""
|
||
if not body_md.strip():
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="暂无方案内容,请先在编辑器中填写或保存方案后再推送",
|
||
)
|
||
existing = (
|
||
db.query(models.ProjectCloudDoc)
|
||
.filter(
|
||
models.ProjectCloudDoc.project_id == project_id,
|
||
models.ProjectCloudDoc.platform == platform,
|
||
)
|
||
.first()
|
||
)
|
||
existing_doc_id = existing.cloud_doc_id if existing else None
|
||
credentials = get_all_credentials()
|
||
manager = CloudDocManager(credentials)
|
||
try:
|
||
cloud_doc_id, url = await manager.push_markdown(
|
||
platform, title, body_md, existing_doc_id=existing_doc_id
|
||
)
|
||
except RuntimeError as e:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||
if existing:
|
||
existing.cloud_doc_id = cloud_doc_id
|
||
existing.cloud_url = url
|
||
existing.updated_at = datetime.now(timezone.utc)
|
||
else:
|
||
record = models.ProjectCloudDoc(
|
||
project_id=project_id,
|
||
platform=platform,
|
||
cloud_doc_id=cloud_doc_id,
|
||
cloud_url=url,
|
||
)
|
||
db.add(record)
|
||
db.commit()
|
||
return PushToCloudResponse(url=url, cloud_doc_id=cloud_doc_id)
|
||
|