fix:优化项目内容
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
from typing import List
|
||||
from datetime import date
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -9,6 +12,8 @@ from backend.app import models
|
||||
from backend.app.schemas import (
|
||||
FinanceRecordRead,
|
||||
FinanceRecordUpdate,
|
||||
FinanceBatchDeleteRequest,
|
||||
FinanceSyncRequest,
|
||||
FinanceSyncResponse,
|
||||
FinanceSyncResult,
|
||||
FinanceUploadResponse,
|
||||
@@ -21,9 +26,14 @@ router = APIRouter(prefix="/finance", tags=["finance"])
|
||||
|
||||
|
||||
@router.post("/sync", response_model=FinanceSyncResponse)
|
||||
async def sync_finance():
|
||||
async def sync_finance(payload: FinanceSyncRequest = Body(default=FinanceSyncRequest())):
|
||||
try:
|
||||
items_raw = await sync_finance_emails()
|
||||
items_raw = await sync_finance_emails(
|
||||
mode=payload.mode,
|
||||
start_date=payload.start_date,
|
||||
end_date=payload.end_date,
|
||||
doc_types=payload.doc_types,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
# 邮箱配置/连接等问题属于可预期的业务错误,用 400 让前端直接展示原因,而不是泛化为 500。
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
@@ -108,6 +118,60 @@ async def update_finance_record(
|
||||
return record
|
||||
|
||||
|
||||
@router.delete("/records/{record_id}")
|
||||
async def delete_finance_record(
|
||||
record_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""删除单条财务记录及对应文件(若存在)。"""
|
||||
record = db.query(models.FinanceRecord).get(record_id)
|
||||
if not record:
|
||||
raise HTTPException(404, "记录不存在")
|
||||
|
||||
file_path = Path(record.file_path)
|
||||
if not file_path.is_absolute():
|
||||
file_path = Path(".") / file_path
|
||||
if file_path.exists():
|
||||
try:
|
||||
file_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
db.delete(record)
|
||||
db.commit()
|
||||
return {"status": "deleted", "id": record_id}
|
||||
|
||||
|
||||
@router.post("/records/batch-delete")
|
||||
async def batch_delete_finance_records(
|
||||
payload: FinanceBatchDeleteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""批量删除财务记录及对应文件。"""
|
||||
if not payload.ids:
|
||||
return {"status": "ok", "deleted": 0}
|
||||
|
||||
records = (
|
||||
db.query(models.FinanceRecord)
|
||||
.filter(models.FinanceRecord.id.in_(payload.ids))
|
||||
.all()
|
||||
)
|
||||
|
||||
for record in records:
|
||||
file_path = Path(record.file_path)
|
||||
if not file_path.is_absolute():
|
||||
file_path = Path(".") / file_path
|
||||
if file_path.exists():
|
||||
try:
|
||||
file_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
db.delete(record)
|
||||
|
||||
db.commit()
|
||||
return {"status": "deleted", "deleted": len(records)}
|
||||
|
||||
|
||||
@router.get("/download/{month}")
|
||||
async def download_finance_month(month: str):
|
||||
"""
|
||||
@@ -124,3 +188,53 @@ async def download_finance_month(month: str):
|
||||
filename=f"finance_{month}.zip",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/download-range")
|
||||
async def download_finance_range(
|
||||
start_date: date = Query(..., description="起始日期 YYYY-MM-DD"),
|
||||
end_date: date = Query(..., description="结束日期 YYYY-MM-DD(含当日)"),
|
||||
only_invoices: bool = Query(True, description="是否仅包含发票类型"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
按时间范围打包下载发票(默认仅发票,可扩展)。
|
||||
"""
|
||||
if end_date < start_date:
|
||||
raise HTTPException(status_code=400, detail="结束日期不能早于开始日期")
|
||||
|
||||
q = db.query(models.FinanceRecord).filter(
|
||||
models.FinanceRecord.billing_date.isnot(None),
|
||||
models.FinanceRecord.billing_date >= start_date,
|
||||
models.FinanceRecord.billing_date <= end_date,
|
||||
)
|
||||
if only_invoices:
|
||||
q = q.filter(models.FinanceRecord.type == "invoices")
|
||||
|
||||
records = q.all()
|
||||
if not records:
|
||||
raise HTTPException(status_code=404, detail="该时间段内没有可导出的记录")
|
||||
|
||||
base_dir = Path("data/finance")
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
zip_name = f"invoices_{start_date.isoformat()}_{end_date.isoformat()}.zip"
|
||||
zip_path = base_dir / zip_name
|
||||
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for r in records:
|
||||
file_path = Path(r.file_path)
|
||||
if not file_path.is_absolute():
|
||||
file_path = Path(".") / file_path
|
||||
if not file_path.exists():
|
||||
continue
|
||||
# 保持月份/类型的相对结构
|
||||
rel = file_path.relative_to(Path("data")) if "data" in file_path.parts else file_path.name
|
||||
zf.write(file_path, arcname=rel)
|
||||
|
||||
return FileResponse(
|
||||
path=str(zip_path),
|
||||
media_type="application/zip",
|
||||
filename=zip_name,
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
import json
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from backend.app import models
|
||||
@@ -105,6 +109,7 @@ def _build_markdown_from_analysis(data: Union[Dict[str, Any], List[Any]]) -> str
|
||||
@router.get("/", response_model=list[ProjectRead])
|
||||
async def list_projects(
|
||||
customer_tag: str | None = None,
|
||||
limit: int = Query(30, ge=1, le=200, description="默认只返回最近 N 条"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""列表项目;customer_tag 不为空时只返回该客户标签下的项目(按客户 tags 筛选)。"""
|
||||
@@ -127,7 +132,7 @@ async def list_projects(
|
||||
t.ilike(f"%,{tag}"),
|
||||
)
|
||||
)
|
||||
return query.all()
|
||||
return query.limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectRead)
|
||||
@@ -200,6 +205,88 @@ async def analyze_project_requirement(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/analyze_stream")
|
||||
async def analyze_project_requirement_stream(
|
||||
payload: RequirementAnalyzeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
SSE 流式输出 Markdown 到前端(用于编辑器实时显示)。
|
||||
data: {"type":"delta","content":"..."} / {"type":"done","project_id":1}
|
||||
"""
|
||||
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")
|
||||
|
||||
# Create a project first so we can return project_id at end
|
||||
project = models.Project(
|
||||
customer_id=payload.customer_id,
|
||||
raw_requirement=payload.raw_text,
|
||||
ai_solution_md="",
|
||||
status="draft",
|
||||
)
|
||||
db.add(project)
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
|
||||
async def gen() -> AsyncGenerator[str, None]:
|
||||
from backend.app.services.ai_service import get_active_ai_config
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
config = get_active_ai_config()
|
||||
api_key = (config.get("api_key") or "").strip()
|
||||
if not api_key:
|
||||
yield f"data: {json.dumps({'type':'error','message':'AI API Key 未配置'})}\n\n"
|
||||
return
|
||||
base_url = (config.get("base_url") or "").strip() or None
|
||||
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||
model = config.get("model_name") or "gpt-4o-mini"
|
||||
temperature = float(config.get("temperature", 0.2))
|
||||
|
||||
system_prompt = (
|
||||
(config.get("system_prompt_override") or "").strip()
|
||||
or "你是一名资深系统架构师,请输出可直接编辑的 Markdown 方案,不要输出 JSON。"
|
||||
)
|
||||
user_prompt = (
|
||||
"请基于以下客户原始需求,输出一份可交付的项目方案草稿(Markdown)。\n"
|
||||
"要求包含:概要、功能模块拆分、技术实现思路、工时与报价估算、备注。\n\n"
|
||||
f"【客户原始需求】\n{payload.raw_text}"
|
||||
)
|
||||
|
||||
full = ""
|
||||
try:
|
||||
stream = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=temperature,
|
||||
stream=True,
|
||||
)
|
||||
async for event in stream:
|
||||
delta = (event.choices[0].delta.content or "") if event.choices else ""
|
||||
if not delta:
|
||||
continue
|
||||
full += delta
|
||||
yield f"data: {json.dumps({'type':'delta','content':delta}, ensure_ascii=False)}\n\n"
|
||||
except Exception as exc:
|
||||
yield f"data: {json.dumps({'type':'error','message':str(exc)}, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
# Save final markdown
|
||||
try:
|
||||
project.ai_solution_md = full
|
||||
db.add(project)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
yield f"data: {json.dumps({'type':'done','project_id':project.id}, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.post("/{project_id}/generate_quote", response_model=QuoteGenerateResponse)
|
||||
async def generate_project_quote(
|
||||
project_id: int,
|
||||
|
||||
Reference in New Issue
Block a user