Files
TradeMate Dev 79474d8480 feat: quotation credit deduction + production restart
- Quotation generate-from-inquiry deducts 2 credits
- Backend restarted and verified API endpoints work
- Credit seed data committed to database
- All credit APIs returning correct data
2026-06-12 11:07:08 +08:00

255 lines
8.4 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query, Response, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from pydantic import BaseModel
from app.database import get_db
from app.services.quotation import QuotationService
from app.services.pdf_generator import pdf_generator
from app.services import export
from app.services.credit import CreditService
from app.api.v1.deps import get_current_user_id
from app.models.quotation import Quotation
from app.models.customer import Customer
from sqlalchemy import select, and_
import logging
import io
logger = logging.getLogger(__name__)
try:
import openpyxl
HAS_OPENPYXL = True
except ImportError:
HAS_OPENPYXL = False
router = APIRouter()
class InquiryRequest(BaseModel):
inquiry_text: str
customer_id: Optional[str] = None
@router.post("/generate-from-inquiry")
async def generate_from_inquiry(
data: InquiryRequest,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
credit_svc = CreditService(db)
ok, balance = await credit_svc.deduct(user_id, "quotation")
if not ok:
raise HTTPException(
status_code=402,
detail=f"次数不足 (剩余 {balance:.1f}, 需要 2)"
)
service = QuotationService(db)
result = await service.generate_from_inquiry(
user_id=user_id,
inquiry_text=data.inquiry_text,
customer_id=data.customer_id,
)
return result
@router.post("")
async def create_quotation(
data: dict,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = QuotationService(db)
try:
quotation = await service.create_quotation(user_id, data)
return quotation
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("")
async def list_quotations(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = QuotationService(db)
return await service.list_quotations(user_id, page, size)
@router.post("/import")
async def import_quotations(
file: UploadFile = File(...),
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = QuotationService(db)
content = await file.read()
filename = file.filename.lower()
if filename.endswith(".xlsx"):
if not HAS_OPENPYXL:
raise HTTPException(status_code=501, detail="openpyxl not installed, XLSX import unavailable")
wb = openpyxl.load_workbook(io.BytesIO(content))
ws = wb.active
rows = list(ws.iter_rows(min_row=2, values_only=True))
records = []
for row in rows:
if not row[0]:
continue
items = [{"product_name": "Imported Item", "quantity": 1, "unit_price": float(row[3]) if len(row) > 3 and row[3] else 0}]
records.append({
"title": str(row[0]),
"customer_id": str(row[1]) if len(row) > 1 and row[1] else "",
"currency": str(row[2]) if len(row) > 2 and row[2] in ("USD", "EUR", "GBP", "CNY") else "USD",
"items": items,
"status": "draft",
})
elif filename.endswith(".csv"):
import csv
reader = csv.DictReader(io.StringIO(content.decode("utf-8-sig")))
records = []
for row in reader:
title = row.get("Title", row.get("标题", "")).strip()
if not title:
continue
items = [{"product_name": "Imported Item", "quantity": 1, "unit_price": float(row.get("Total", row.get("总计", 0)))}]
records.append({
"title": title,
"customer_id": row.get("Customer", row.get("客户", "")),
"currency": row.get("Currency", row.get("货币", "USD")),
"items": items,
})
else:
raise HTTPException(status_code=400, detail="Unsupported file format. Use .xlsx or .csv")
imported = 0
errors = []
for rec in records:
try:
await service.create_quotation(user_id, rec)
imported += 1
except Exception as e:
errors.append(f"{rec.get('title', '')}: {str(e)}")
return {"imported": imported, "errors": errors}
@router.get("/{quotation_id}")
async def get_quotation(
quotation_id: str,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = QuotationService(db)
quotation = await service.get_quotation(user_id, quotation_id)
if not quotation:
raise HTTPException(status_code=404, detail="Quotation not found")
return quotation
@router.patch("/{quotation_id}/status")
async def update_quotation_status(
quotation_id: str,
data: dict,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = QuotationService(db)
quotation = await service.update_status(user_id, quotation_id, data.get("status", "draft"))
if not quotation:
raise HTTPException(status_code=404, detail="Quotation not found")
return quotation
@router.get("/export/csv")
async def export_quotations(
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = QuotationService(db)
result = await service.list_quotations(user_id, 1, 9999)
items = result.get("items", [])
csv_bytes = export.export_quotations_csv(items)
return Response(
content=csv_bytes,
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=quotations.csv"},
)
@router.get("/export/xlsx")
async def export_quotations_xlsx(
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = QuotationService(db)
result = await service.list_quotations(user_id, 1, 9999)
items = result.get("items", [])
xlsx_bytes = export.export_quotations_xlsx(items)
return Response(
content=xlsx_bytes,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=quotations.xlsx"},
)
@router.get("/{quotation_id}/pdf")
async def export_quotation_pdf(
quotation_id: str,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = QuotationService(db)
quotation = await service.get_quotation(user_id, quotation_id)
if not quotation:
raise HTTPException(status_code=404, detail="Quotation not found")
result = await db.execute(
select(Customer).where(Customer.id == quotation["customer_id"])
)
customer = result.scalar_one_or_none()
pdf_data = pdf_generator.generate_quotation({
"quotation_number": f"{quotation_id[:8].upper()}",
"customer_name": customer.name if customer else "",
"customer_company": customer.company if customer else "",
"customer_country": customer.country if customer else "",
"date": quotation["created_at"][:10] if quotation.get("created_at") else "",
"valid_until": quotation.get("valid_until", ""),
"currency": quotation.get("currency", "USD"),
"items": quotation.get("items", []),
"subtotal": quotation.get("subtotal", 0),
"discount": quotation.get("discount", 0),
"shipping": quotation.get("shipping", 0),
"total": quotation.get("total", 0),
"payment_terms": quotation.get("payment_terms", ""),
"delivery_terms": quotation.get("delivery_terms", ""),
"lead_time": quotation.get("lead_time", ""),
"notes": quotation.get("notes", ""),
})
if not pdf_data:
raise HTTPException(status_code=501, detail="PDF generation not available (weasyprint not installed)")
service = QuotationService(db)
result = await db.execute(
select(Quotation).where(
and_(Quotation.id == quotation_id, Quotation.user_id == user_id)
)
)
q = result.scalar_one_or_none()
if q:
pdf_url = f"/quotations/{quotation_id}/pdf"
q.pdf_url = pdf_url
await db.flush()
return Response(
content=pdf_data,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="quotation-{quotation_id[:8]}.pdf"',
},
)