7b62c2f8b4
## H5 底部导航修复 (Bug #10) - 精简 App.vue,移除重复 tabbar,仅保留全局样式 - uni-page 设置 height: calc(100% - 50px) + overflow-y: auto - 内容区域精确停在底部导航上方,独立滚动不再叠加 - 恢复 custom-tab-bar 组件 ## 项目进度文档 - PROGRESS.md 更新至 10 个 Bug 修复 - 新增 H5 底部导航修复记录 - 新增历史变更条目
298 lines
11 KiB
Python
298 lines
11 KiB
Python
from typing import Dict, Any, Optional, List
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, and_, or_
|
|
from app.models.quotation import Quotation, QuotationItem
|
|
from app.models.customer import Customer
|
|
from app.models.user import Product
|
|
from app.ai.router import get_ai_router
|
|
from datetime import datetime
|
|
import logging
|
|
import json
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class QuotationService:
|
|
def __init__(self, db: AsyncSession):
|
|
self.db = db
|
|
|
|
async def create_quotation(self, user_id: str, data: Dict[str, Any]) -> Dict:
|
|
items_data = data.pop("items", [])
|
|
|
|
if data.get("customer_id"):
|
|
cust_result = await self.db.execute(
|
|
select(Customer).where(
|
|
and_(Customer.id == data["customer_id"], Customer.user_id == user_id)
|
|
)
|
|
)
|
|
if not cust_result.scalar_one_or_none():
|
|
raise ValueError("Customer not found")
|
|
|
|
q = Quotation(user_id=user_id, **data)
|
|
self.db.add(q)
|
|
await self.db.flush()
|
|
|
|
total = 0
|
|
for item_data in items_data:
|
|
item_total = item_data.get("quantity", 0) * item_data.get("unit_price", 0)
|
|
item = QuotationItem(
|
|
quotation_id=q.id,
|
|
product_name=item_data["product_name"],
|
|
description=item_data.get("description"),
|
|
quantity=item_data["quantity"],
|
|
unit_price=item_data["unit_price"],
|
|
total_price=item_total,
|
|
unit=item_data.get("unit", "pcs"),
|
|
)
|
|
self.db.add(item)
|
|
total += item_total
|
|
|
|
q.subtotal = total
|
|
q.total = total - (data.get("discount", 0)) + (data.get("shipping", 0))
|
|
await self.db.flush()
|
|
|
|
return await self._to_dict(q)
|
|
|
|
async def get_quotation(self, user_id: str, quotation_id: str) -> Optional[Dict]:
|
|
result = await self.db.execute(
|
|
select(Quotation).where(
|
|
and_(Quotation.id == quotation_id, Quotation.user_id == user_id)
|
|
)
|
|
)
|
|
q = result.scalar_one_or_none()
|
|
return await self._to_dict(q) if q else None
|
|
|
|
async def list_quotations(self, user_id: str, page: int = 1, size: int = 20) -> Dict[str, Any]:
|
|
from sqlalchemy import func
|
|
query = select(Quotation).where(Quotation.user_id == user_id).order_by(Quotation.created_at.desc()).offset((page - 1) * size).limit(size)
|
|
count_query = select(func.count()).select_from(Quotation).where(Quotation.user_id == user_id)
|
|
|
|
total = await self.db.execute(count_query)
|
|
result = await self.db.execute(query)
|
|
quotations = result.scalars().all()
|
|
|
|
items = []
|
|
for q in quotations:
|
|
items.append(await self._to_dict(q))
|
|
|
|
return {"items": items, "total": total.scalar(), "page": page, "size": size}
|
|
|
|
async def update_status(self, user_id: str, quotation_id: str, status: str) -> Optional[Dict]:
|
|
result = await self.db.execute(
|
|
select(Quotation).where(
|
|
and_(Quotation.id == quotation_id, Quotation.user_id == user_id)
|
|
)
|
|
)
|
|
q = result.scalar_one_or_none()
|
|
if not q:
|
|
return None
|
|
q.status = status
|
|
if status == "sent":
|
|
q.sent_at = datetime.utcnow()
|
|
await self.db.flush()
|
|
return await self._to_dict(q)
|
|
|
|
async def generate_from_inquiry(
|
|
self, user_id: str, inquiry_text: str, customer_id: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
ai = get_ai_router()
|
|
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"product_requests": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"product_name": {"type": "string"},
|
|
"quantity": {"type": "integer"},
|
|
"unit": {"type": "string"},
|
|
"specifications": {"type": "string"},
|
|
"target_price": {"type": "string"},
|
|
},
|
|
},
|
|
},
|
|
"payment_terms": {"type": "string"},
|
|
"delivery_terms": {"type": "string"},
|
|
"urgency": {"type": "string"},
|
|
},
|
|
}
|
|
|
|
extract_result = await ai.extract(inquiry_text, schema)
|
|
extracted = extract_result.get("data", {})
|
|
product_requests = extracted.get("product_requests", [])
|
|
|
|
if not product_requests:
|
|
schema_simple = {
|
|
"type": "object",
|
|
"properties": {
|
|
"product_name": {"type": "string"},
|
|
"quantity": {"type": "integer"},
|
|
"specifications": {"type": "string"},
|
|
},
|
|
}
|
|
extract_result = await ai.extract(inquiry_text, schema_simple)
|
|
extracted = extract_result.get("data", {})
|
|
if extracted.get("product_name"):
|
|
product_requests = [{
|
|
"product_name": extracted["product_name"],
|
|
"quantity": extracted.get("quantity", 1),
|
|
"unit": "pcs",
|
|
"specifications": extracted.get("specifications", ""),
|
|
}]
|
|
|
|
product_result = await self.db.execute(
|
|
select(Product).where(
|
|
and_(
|
|
Product.user_id == user_id,
|
|
Product.is_active == True,
|
|
)
|
|
)
|
|
)
|
|
user_products = product_result.scalars().all()
|
|
|
|
matched_products = []
|
|
for req in product_requests:
|
|
req_name = req.get("product_name", "").lower()
|
|
best_match = None
|
|
best_score = 0
|
|
|
|
for p in user_products:
|
|
score = 0
|
|
p_name = (p.name or "").lower()
|
|
p_name_en = (p.name_en or "").lower()
|
|
|
|
if req_name in p_name or p_name in req_name:
|
|
score += 3
|
|
if req_name in p_name_en or p_name_en in req_name:
|
|
score += 2
|
|
|
|
keywords = p.keywords or []
|
|
for kw in keywords:
|
|
if isinstance(kw, str) and kw.lower() in req_name:
|
|
score += 1
|
|
|
|
if score > best_score:
|
|
best_score = score
|
|
best_match = p
|
|
|
|
if best_match and best_score > 0:
|
|
unit_price = float(best_match.price) if best_match.price else 0
|
|
quantity = req.get("quantity", 1)
|
|
matched_products.append({
|
|
"product_id": str(best_match.id),
|
|
"product_name": best_match.name,
|
|
"description": best_match.description_en or best_match.description,
|
|
"quantity": quantity,
|
|
"unit_price": unit_price,
|
|
"total_price": unit_price * quantity,
|
|
"unit": req.get("unit", "pcs"),
|
|
"match_score": best_score,
|
|
})
|
|
else:
|
|
matched_products.append({
|
|
"product_id": None,
|
|
"product_name": req.get("product_name", "Unknown"),
|
|
"description": req.get("specifications", ""),
|
|
"quantity": req.get("quantity", 1),
|
|
"unit_price": 0,
|
|
"total_price": 0,
|
|
"unit": req.get("unit", "pcs"),
|
|
"match_score": 0,
|
|
})
|
|
|
|
subtotal = sum(p["total_price"] for p in matched_products)
|
|
total = subtotal
|
|
|
|
suggested_quotation = {
|
|
"title": f"Quotation - {', '.join(p['product_name'] for p in matched_products[:3])}",
|
|
"items": matched_products,
|
|
"subtotal": subtotal,
|
|
"total": total,
|
|
"payment_terms": extracted.get("payment_terms", "T/T"),
|
|
"delivery_terms": extracted.get("delivery_terms", "FOB"),
|
|
"lead_time": "15-20 days" if extracted.get("urgency") != "urgent" else "7-10 days",
|
|
"notes": f"Generated from customer inquiry: {inquiry_text[:100]}..." if len(inquiry_text) > 100 else f"Generated from customer inquiry: {inquiry_text}",
|
|
"extracted_data": extracted,
|
|
"matched_count": len([p for p in matched_products if p["product_id"]]),
|
|
"unmatched_count": len([p for p in matched_products if not p["product_id"]]),
|
|
}
|
|
|
|
return suggested_quotation
|
|
|
|
async def generate_quotation_text(self, q: Quotation) -> str:
|
|
items_result = await self.db.execute(
|
|
select(QuotationItem).where(QuotationItem.quotation_id == q.id)
|
|
)
|
|
items = items_result.scalars().all()
|
|
|
|
lines = [f"QUOTATION", f"", f"Date: {datetime.utcnow().strftime('%Y-%m-%d')}"]
|
|
if q.valid_until:
|
|
lines.append(f"Valid until: {q.valid_until}")
|
|
lines.append(f"")
|
|
lines.append(f"{'Item':<30} {'Qty':<10} {'Unit Price':<15} {'Total':<15}")
|
|
lines.append("-" * 70)
|
|
|
|
for item in items:
|
|
lines.append(f"{item.product_name:<30} {item.quantity:<10} ${item.unit_price:<12.2f} ${item.total_price:<10.2f}")
|
|
|
|
lines.append("-" * 70)
|
|
if q.subtotal:
|
|
lines.append(f"{'Subtotal':>55} ${q.subtotal:<10.2f}")
|
|
if q.discount:
|
|
lines.append(f"{'Discount':>55} -${q.discount:<9.2f}")
|
|
if q.shipping:
|
|
lines.append(f"{'Shipping':>55} ${q.shipping:<10.2f}")
|
|
lines.append(f"{'TOTAL':>55} ${q.total or q.subtotal or 0:<10.2f}")
|
|
lines.append(f"")
|
|
if q.payment_terms:
|
|
lines.append(f"Payment: {q.payment_terms}")
|
|
if q.delivery_terms:
|
|
lines.append(f"Delivery: {q.delivery_terms}")
|
|
if q.lead_time:
|
|
lines.append(f"Lead time: {q.lead_time}")
|
|
if q.notes:
|
|
lines.append(f"")
|
|
lines.append(f"Notes: {q.notes}")
|
|
|
|
return "\n".join(lines)
|
|
|
|
async def _to_dict(self, q: Quotation) -> Dict:
|
|
items_result = await self.db.execute(
|
|
select(QuotationItem).where(QuotationItem.quotation_id == q.id)
|
|
)
|
|
items = items_result.scalars().all()
|
|
|
|
return {
|
|
"id": str(q.id),
|
|
"customer_id": str(q.customer_id) if q.customer_id else None,
|
|
"title": q.title,
|
|
"status": q.status,
|
|
"currency": q.currency,
|
|
"exchange_rate": q.exchange_rate,
|
|
"payment_terms": q.payment_terms,
|
|
"delivery_terms": q.delivery_terms,
|
|
"lead_time": q.lead_time,
|
|
"valid_until": q.valid_until,
|
|
"subtotal": q.subtotal,
|
|
"discount": q.discount,
|
|
"shipping": q.shipping,
|
|
"total": q.total,
|
|
"notes": q.notes,
|
|
"items": [
|
|
{
|
|
"product_name": i.product_name,
|
|
"description": i.description,
|
|
"quantity": i.quantity,
|
|
"unit_price": i.unit_price,
|
|
"total_price": i.total_price,
|
|
"unit": i.unit,
|
|
}
|
|
for i in items
|
|
],
|
|
"text": await self.generate_quotation_text(q),
|
|
"sent_at": q.sent_at.isoformat() if q.sent_at else None,
|
|
"created_at": q.created_at.isoformat() if q.created_at else None,
|
|
}
|