fix:优化项目内容

This commit is contained in:
Daniel
2026-03-18 17:01:10 +08:00
parent da63282a10
commit 27dc89e251
64 changed files with 3421 additions and 4982 deletions

View File

@@ -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,
)

View File

@@ -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,