79474d8480
- 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
255 lines
8.4 KiB
Python
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"',
|
|
},
|
|
)
|